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
Generated
+11
View File
@@ -267,11 +267,13 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"aura-crypto", "aura-crypto",
"aura-pki",
"aura-proto", "aura-proto",
"bytes", "bytes",
"quinn", "quinn",
"rand 0.8.6", "rand 0.8.6",
"rustls", "rustls",
"rustls-pemfile",
"rustls-pki-types", "rustls-pki-types",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
@@ -2205,6 +2207,15 @@ dependencies = [
"security-framework", "security-framework",
] ]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.1" version = "1.14.1"
+8
View File
@@ -18,3 +18,11 @@ tracing.workspace = true
thiserror.workspace = true thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
async-trait.workspace = true async-trait.workspace = true
# PEM (certificates + PKCS#8 keys) -> DER for the outer QUIC/TLS rustls config. Already resolved
# in the workspace lockfile (pulled transitively), so this adds no new version resolution.
rustls-pemfile = "2"
[dev-dependencies]
# The loopback integration test mints a CA + server/client certs to drive a real QUIC handshake.
aura-pki.workspace = true
tokio.workspace = true
+103
View File
@@ -0,0 +1,103 @@
//! [`AuraConnection`]: an established, authenticated Aura session exposed as a full-duplex packet
//! pipe (project §7, [`aura_proto::PacketConnection`]).
//!
//! After both the outer QUIC handshake and the inner Aura proto handshake complete, the resulting
//! [`aura_proto::Session`] is wrapped here. The session is [`split`](aura_proto::Session::split)
//! into its send and receive halves, each parked behind its own [`tokio::sync::Mutex`]. Two separate
//! mutexes (rather than one over the whole session) is the point: a sender task and a receiver task
//! can hold their respective locks simultaneously, so `send_packet` and `recv_packet` run truly
//! concurrently — which is exactly how the tunnel router drives this (one task per direction).
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use quinn::{RecvStream, SendStream};
use tokio::sync::Mutex;
use aura_proto::{Frame, PacketConnection, Session, SessionReceiver, SessionSender};
/// The concrete session type carried over QUIC: proto session over quinn's stream halves.
type QuicSession = Session<RecvStream, SendStream>;
/// An established Aura connection: a QUIC-carried, hybrid-PQ + mutually-X.509-authenticated session,
/// usable as a one-IP-packet-per-call duplex pipe.
///
/// Implements [`aura_proto::PacketConnection`], so it can be shared as
/// `Arc<dyn aura_proto::PacketConnection>` across concurrent send/receive tasks (the methods take
/// `&self`). Outbound packets are sent as [`Frame::Data`] on `stream_id 0`; inbound `Data` frames'
/// payloads are returned, while `Ping`/`Pong`/`Close` are handled transparently (see
/// [`recv_packet`](AuraConnection::recv_packet)).
pub struct AuraConnection {
sender: Mutex<SessionSender<SendStream>>,
receiver: Mutex<SessionReceiver<RecvStream>>,
/// The verified peer Common Name captured before the session was split (the server learns the
/// client id; the client learns the server name).
peer_id: Option<String>,
}
impl AuraConnection {
/// Wrap an established proto [`Session`] (already past both handshakes) for packet I/O.
#[must_use]
pub fn from_session(session: QuicSession) -> Self {
let peer_id = session.peer_id().map(str::to_owned);
let (sender, receiver) = session.split();
Self {
sender: Mutex::new(sender),
receiver: Mutex::new(receiver),
peer_id,
}
}
/// The verified identity (Common Name) of the peer, if one was established during the handshake.
#[must_use]
pub fn peer_id(&self) -> Option<&str> {
self.peer_id.as_deref()
}
/// Convenience: wrap this connection as a trait object for the tunnel layer.
#[must_use]
pub fn into_dyn(self) -> Arc<dyn PacketConnection> {
Arc::new(self)
}
}
#[async_trait]
impl PacketConnection for AuraConnection {
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
let frame = Frame::Data {
stream_id: 0,
payload: Bytes::copy_from_slice(packet),
};
self.sender.lock().await.send_frame(frame).await?;
Ok(())
}
async fn recv_packet(&self) -> anyhow::Result<Vec<u8>> {
// Hold the receive lock across the await: only one receiver task drains the stream, and the
// proto framing is sequential, so concurrent recvs must not interleave on the same half.
let mut receiver = self.receiver.lock().await;
loop {
match receiver.recv_frame().await? {
Frame::Data { payload, .. } => return Ok(payload.to_vec()),
// Liveness keep-alive: answer a Ping with the matching Pong, then keep waiting for
// real data. The sender is a *separate* mutex, so taking it here cannot deadlock
// against the receive lock we already hold.
Frame::Ping { seq } => {
self.sender
.lock()
.await
.send_frame(Frame::Pong { seq })
.await?;
}
// Stray Pong (we don't currently originate Pings): ignore and keep waiting.
Frame::Pong { .. } => continue,
// A clean Close ends the packet stream; surface it as an error so the caller's
// receive loop terminates rather than spinning.
Frame::Close { code, reason } => {
anyhow::bail!("peer closed connection (code {code}): {reason}");
}
}
}
}
}
+221 -3
View File
@@ -1,4 +1,222 @@
//! aura-transport — QUIC transport and HTTPS/H3 traffic mimicry (skeleton; implemented in Wave 3). //! aura-transport — the Aura VPN's QUIC transport with HTTP/3 traffic mimicry (project §7).
//! //!
//! Implements `aura_proto::PacketConnection` over a QUIC-carried `aura_proto::Session`, and provides //! This crate carries the Aura protocol over real QUIC and exposes an established connection as an
//! the quinn endpoint setup (`quic`), mimicry (`mimicry`), and packet padding (`padding`). //! [`aura_proto::PacketConnection`]. It has two layers, and which one is the security boundary is
//! the key design point:
//!
//! * **Outer = QUIC/TLS mimicry.** The connection is dressed up to look like ordinary browser
//! HTTP/3: ALPN `h3`/`h3-29` and Chrome-like transport parameters (see [`mimicry`]). The outer
//! TLS is **not** the real authentication — so the QUIC client accepts *any* server certificate
//! ([`quic::AcceptAnyServerCert`]). A passive observer sees what looks like a CDN connection.
//! * **Inner = the Aura proto handshake.** Over a single bidirectional QUIC stream we run
//! [`aura_proto::client_handshake`] / [`aura_proto::server_handshake`], which perform the hybrid
//! post-quantum key agreement and **mutual X.509** verification against the Aura CA. *This* is the
//! authentication and the source of the session keys.
//!
//! ## Layout (project §7)
//! * [`quic`] — quinn endpoint/config setup and the dangerous outer-TLS verifier.
//! * [`mimicry`] — ALPN/SNI constants and [`mimicry::chrome_quic_transport_config`].
//! * [`padding`] — [`padding::pad_to_https_size`] / [`padding::inject_padding_frames`] traffic shaping.
//! * [`conn`] — [`AuraConnection`], the [`aura_proto::PacketConnection`] implementation.
//!
//! ## Usage (Wave 4 / CLI)
//! ```no_run
//! # async fn demo(
//! # ca_cert_pem: String, server_cert_pem: String, server_key_pem: String,
//! # client_cert_pem: String, client_key_pem: String, server_name: String,
//! # ) -> anyhow::Result<()> {
//! use aura_transport::{AuraServer, AuraClient, PacketConnection};
//! use aura_proto::{ServerConfig, ClientConfig};
//!
//! // Server: bind, then accept authenticated connections in a loop.
//! let server = AuraServer::bind(
//! "0.0.0.0:4433".parse()?,
//! &server_cert_pem, // outer QUIC cert (may equal the Aura server cert)
//! &server_key_pem,
//! ServerConfig {
//! ca_cert_pem: ca_cert_pem.clone(),
//! server_cert_pem: server_cert_pem.clone(),
//! server_key_pem: server_key_pem.clone(),
//! },
//! )?;
//! let server_conn = server.accept().await?; // -> AuraConnection
//!
//! // Client: connect to the server's address with a camouflage SNI.
//! let client_conn = AuraClient::connect(
//! "203.0.113.10:4433".parse()?,
//! "cdn.example.com", // outer SNI (mimicry)
//! ClientConfig { ca_cert_pem, client_cert_pem, client_key_pem, server_name },
//! ).await?;
//!
//! // Either side: use it as a packet pipe (also works behind Arc<dyn PacketConnection>).
//! client_conn.send_packet(b"\x45\x00 ...ip packet... ").await?;
//! let pkt = server_conn.recv_packet().await?;
//! # let _ = pkt; Ok(())
//! # }
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod conn;
pub mod mimicry;
pub mod padding;
pub mod quic;
pub use conn::AuraConnection;
pub use mimicry::{alpn_protocols, chrome_quic_transport_config, ALPN_H3, DEFAULT_SNI};
pub use padding::{inject_padding_frames, pad_to_https_size, HTTPS_SIZE_BUCKETS};
pub use quic::{client_endpoint, server_endpoint, AcceptAnyServerCert};
// Re-export the inner proto trait so downstream crates (the CLI) can name the connection as
// `Arc<dyn aura_transport::PacketConnection>` without a separate `aura_proto` import.
pub use aura_proto::PacketConnection;
use std::net::SocketAddr;
use std::sync::Arc;
use aura_proto::{client_handshake, server_handshake, ClientConfig, ServerConfig};
use thiserror::Error;
/// Errors produced by the Aura transport layer.
#[derive(Debug, Error)]
pub enum TransportError {
/// A PEM blob (certificate or private key) could not be parsed.
#[error("PEM parse error: {0}")]
Pem(String),
/// Building or converting a rustls/quic TLS configuration failed.
#[error("TLS configuration error: {0}")]
Tls(String),
/// Binding, connecting, or operating the quinn endpoint failed (includes the QUIC handshake).
#[error("QUIC transport error: {0}")]
Quic(String),
/// The inner Aura protocol handshake failed.
#[error("Aura handshake error: {0}")]
Handshake(#[from] aura_proto::ProtoError),
/// A generic I/O error (e.g. binding the UDP socket).
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
// quinn's connect/handshake errors are distinct types; fold them into one transport error.
impl From<quinn::ConnectError> for TransportError {
fn from(e: quinn::ConnectError) -> Self {
TransportError::Quic(format!("connect: {e}"))
}
}
impl From<quinn::ConnectionError> for TransportError {
fn from(e: quinn::ConnectionError) -> Self {
TransportError::Quic(format!("connection: {e}"))
}
}
/// An Aura VPN server: a bound QUIC endpoint that accepts authenticated [`AuraConnection`]s.
///
/// Each [`accept`](AuraServer::accept) performs the outer QUIC accept, opens the inner bidirectional
/// stream, runs [`aura_proto::server_handshake`] (mutual auth against the CA), and returns a ready
/// [`AuraConnection`].
pub struct AuraServer {
endpoint: quinn::Endpoint,
proto_cfg: Arc<ServerConfig>,
}
impl AuraServer {
/// Bind a server on `addr`.
///
/// * `addr` — UDP address to listen on; use `..:0` for an OS-assigned port and read it back with
/// [`AuraServer::local_addr`].
/// * `outer_cert_pem` / `outer_key_pem` — the **outer** QUIC/TLS (mimicry) certificate and key.
/// These may be the same PEM as the Aura server cert in `proto_cfg` (and typically are).
/// * `proto_cfg` — the inner Aura handshake config (CA + server leaf cert/key) used to mutually
/// authenticate each client.
///
/// # Errors
/// Returns [`TransportError`] if the certs/keys are unparsable or the UDP socket cannot bind.
pub fn bind(
addr: SocketAddr,
outer_cert_pem: &str,
outer_key_pem: &str,
proto_cfg: ServerConfig,
) -> Result<Self, TransportError> {
let endpoint = quic::server_endpoint(addr, outer_cert_pem, outer_key_pem)?;
Ok(Self {
endpoint,
proto_cfg: Arc::new(proto_cfg),
})
}
/// The local address (including the OS-assigned port) this server is bound to.
///
/// # Errors
/// Returns [`TransportError::Io`] if the underlying socket address cannot be read.
pub fn local_addr(&self) -> Result<SocketAddr, TransportError> {
Ok(self.endpoint.local_addr()?)
}
/// Accept the next client: outer QUIC handshake, then the inner Aura mutual-auth handshake.
///
/// Returns a ready [`AuraConnection`] whose [`peer_id`](AuraConnection::peer_id) is the verified
/// client Common Name. Call this in a loop (optionally spawning a task per connection).
///
/// # Errors
/// Returns [`TransportError`] if the endpoint is closed, the QUIC handshake fails, or the inner
/// Aura handshake fails (e.g. the client's certificate does not verify against the CA).
pub async fn accept(&self) -> Result<AuraConnection, TransportError> {
let incoming = self
.endpoint
.accept()
.await
.ok_or_else(|| TransportError::Quic("endpoint closed".into()))?;
let connection = incoming.await?;
// The client opens the bidi stream by writing the first ClientHello byte; accept it.
let (send, recv) = connection.accept_bi().await?;
// proto reader = RecvStream, proto writer = SendStream.
let session = server_handshake(recv, send, &self.proto_cfg).await?;
Ok(AuraConnection::from_session(session))
}
/// Access the underlying quinn endpoint (e.g. for graceful shutdown via `close`/`wait_idle`).
#[must_use]
pub fn endpoint(&self) -> &quinn::Endpoint {
&self.endpoint
}
}
/// An Aura VPN client entry point.
pub struct AuraClient;
impl AuraClient {
/// Connect to an Aura server at `server_addr`, presenting `sni` as the outer (mimicry) hostname.
///
/// Performs the outer QUIC connect (accepting any server cert — see crate docs), opens a single
/// bidirectional stream, and runs [`aura_proto::client_handshake`] for hybrid-PQ key agreement
/// and mutual X.509 auth using `proto_cfg`.
///
/// * `server_addr` — the server's UDP socket address.
/// * `sni` — the Server Name Indication to present on the outer TLS (camouflage, e.g.
/// `"cdn.example.com"`); this is independent of `proto_cfg.server_name`, which is the name
/// verified *inside* the Aura handshake against the server's real certificate.
/// * `proto_cfg` — CA + client leaf cert/key + expected server name for the inner handshake.
///
/// # Errors
/// Returns [`TransportError`] if the QUIC connect/handshake fails or the inner Aura handshake
/// fails (e.g. the server cert does not chain to the CA or its SAN does not match
/// `proto_cfg.server_name`).
pub async fn connect(
server_addr: SocketAddr,
sni: &str,
proto_cfg: ClientConfig,
) -> Result<AuraConnection, TransportError> {
let endpoint = quic::client_endpoint()?;
let connection = endpoint.connect(server_addr, sni)?.await?;
// open_bi() reserves the stream; the first write (the ClientHello inside the handshake)
// actually opens it on the wire.
let (send, recv) = connection.open_bi().await?;
let session = client_handshake(recv, send, &proto_cfg).await?;
Ok(AuraConnection::from_session(session))
}
}
+87
View File
@@ -0,0 +1,87 @@
//! HTTPS/H3 mimicry configuration (project §7, "outer = mimicry").
//!
//! The outer QUIC/TLS layer is meant to look like ordinary browser HTTP/3 traffic so a passive
//! observer sees what appears to be a connection to a CDN, not a VPN. That disguise is *not* the
//! security boundary — see the crate docs and [`crate::quic::AcceptAnyServerCert`]; the real mutual
//! authentication happens in the inner Aura proto handshake. This module just centralizes the
//! browser-flavored knobs (ALPN, a default SNI, transport tuning) so they are set consistently.
use std::time::Duration;
/// ALPN protocol identifiers advertised on the outer TLS handshake.
///
/// `h3` (RFC 9114) and `h3-29` (a still-seen draft) are exactly what Chrome offers for HTTP/3, so
/// advertising them makes the ClientHello/ServerHello ALPN extension indistinguishable from a real
/// browser's. Both client and server must agree, so they share this list.
pub const ALPN_H3: &[&[u8]] = &[b"h3", b"h3-29"];
/// A plausible default SNI to present when the caller does not specify one.
///
/// Picking a generic CDN-looking hostname keeps the Server Name Indication from screaming "VPN".
/// Callers should normally pass their own camouflage hostname to [`crate::AuraClient::connect`];
/// this is only a fallback.
pub const DEFAULT_SNI: &str = "cdn.example.com";
/// Return the ALPN list as owned `Vec<Vec<u8>>`, the shape rustls' `alpn_protocols` field wants.
#[must_use]
pub fn alpn_protocols() -> Vec<Vec<u8>> {
ALPN_H3.iter().map(|p| p.to_vec()).collect()
}
/// Chrome-like QUIC transport timing/flow-control knobs (project §7.1).
///
/// These values mirror what a Chromium HTTP/3 connection uses closely enough that the resulting
/// idle-timeout / keep-alive / flow-control behavior is unremarkable on the wire:
///
/// * `max_idle_timeout` ~ 30s — Chrome's default idle timeout.
/// * `keep_alive_interval` ~ 15s — half the idle timeout, so an otherwise-quiet tunnel stays up.
/// * `max_concurrent_bidi_streams` = 100 — a browser-ish concurrency ceiling.
/// * receive windows ~ 10 MB — generous stream/connection flow-control windows so bulk transfer is
/// not throttled (and matches a browser doing large downloads).
///
/// Returned by value so callers wrap it in `Arc` and hand it to both the client and server
/// `quinn::*Config` (keeping the two ends symmetric, which also aids the disguise).
#[must_use]
pub fn chrome_quic_transport_config() -> quinn::TransportConfig {
/// ~10 MB flow-control window (stream and connection level).
const RECV_WINDOW: u32 = 10 * 1024 * 1024;
let mut tc = quinn::TransportConfig::default();
// 30s idle timeout. `IdleTimeout::try_from(Duration)` only fails for absurdly large durations;
// 30s is always representable, so the expect is unreachable in practice.
let idle = quinn::IdleTimeout::try_from(Duration::from_secs(30))
.expect("30s is a valid QUIC idle timeout");
tc.max_idle_timeout(Some(idle));
tc.keep_alive_interval(Some(Duration::from_secs(15)));
tc.max_concurrent_bidi_streams(100u32.into());
// Keep uni-streams modest; the Aura tunnel only uses one bidi stream, but a browser-like profile
// still permits a handful of unidirectional streams (e.g. H3 control/QPACK streams).
tc.max_concurrent_uni_streams(100u32.into());
tc.stream_receive_window(RECV_WINDOW.into());
tc.receive_window((RECV_WINDOW * 2).into());
tc.send_window(u64::from(RECV_WINDOW) * 2);
tc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alpn_is_h3() {
assert_eq!(ALPN_H3, &[b"h3".as_slice(), b"h3-29".as_slice()]);
let owned = alpn_protocols();
assert_eq!(owned, vec![b"h3".to_vec(), b"h3-29".to_vec()]);
}
#[test]
fn transport_config_builds() {
// §7.1: must construct without panicking (the IdleTimeout conversion is the only fallible
// step, and 30s is always valid).
let _tc = chrome_quic_transport_config();
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Traffic-shaping padding helpers (project §7.2).
//!
//! Aura's outer wire is QUIC dressed up as HTTP/3 (see [`crate::mimicry`]). Real HTTPS/H3 traffic
//! tends to cluster at a handful of record/datagram sizes; padding an Aura payload up to one of
//! those buckets makes a passive size-only classifier less able to single it out. These helpers are
//! deliberately simple, allocation-light, and fully unit-tested so the higher layers can call them
//! with confidence.
//!
//! Note these operate on the *application* payload before it is handed to the proto/QUIC layers.
//! They do not, and cannot, hide QUIC's own framing — they only normalize plaintext lengths so the
//! ciphertext lands in a common size class.
use rand::Rng;
/// The HTTPS/H3-like size buckets a payload is rounded up to, in ascending order.
///
/// The first five (64 / 128 / 256 / 512 / 1024) are common small-record sizes; 1280 is the IPv6
/// minimum-MTU "safe" QUIC datagram size; 1460 is a typical Ethernet TCP/QUIC payload (1500-byte
/// MTU minus IP+UDP headers). Keep this sorted ascending: [`pad_to_https_size`] relies on it.
pub const HTTPS_SIZE_BUCKETS: [usize; 7] = [64, 128, 256, 512, 1024, 1280, 1460];
/// The largest bucket in [`HTTPS_SIZE_BUCKETS`]; payloads at or above this are left unpadded.
pub const MAX_BUCKET: usize = HTTPS_SIZE_BUCKETS[HTTPS_SIZE_BUCKETS.len() - 1];
/// Pad `packet` (in place, appending zero bytes) up to the next HTTPS-like size bucket.
///
/// Behavior:
/// * If `packet.len()` is already exactly a bucket, it is left unchanged (idempotent).
/// * Otherwise it grows to the smallest bucket strictly larger than its current length.
/// * **At or over the largest bucket ([`MAX_BUCKET`] = 1460):** the packet is left unchanged. We do
/// not pad to a multiple of 1460, because a single Aura payload is expected to fit within one
/// datagram; over-MTU payloads are the caller's concern (e.g. they will be split by QUIC anyway),
/// and rounding them up would only waste bandwidth without improving the size-class disguise.
///
/// Padding is appended as zero bytes; this is a length-shaping primitive, not an authenticated
/// framing scheme — the proto layer seals the whole (already-padded) payload with AEAD, so the pad
/// bytes are encrypted on the wire. Callers that need to *recover* the original length must carry it
/// themselves (e.g. an inner length prefix); for Aura's IP-packet payloads the IP total-length field
/// already bounds the real data, so trailing zeros are simply ignored by the receiver.
pub fn pad_to_https_size(packet: &mut Vec<u8>) {
let len = packet.len();
// Smallest bucket that can already hold `len` (>=, so an exact-bucket length is a no-op and the
// operation is idempotent). If `len` exceeds every bucket, no padding is applied.
if let Some(&target) = HTTPS_SIZE_BUCKETS.iter().find(|&&b| b >= len) {
packet.resize(target, 0);
}
}
/// Return the bucket `len` would be padded *up to* by [`pad_to_https_size`], or `len` itself if it
/// is at/over [`MAX_BUCKET`]. Useful for sizing buffers ahead of time and for tests.
#[must_use]
pub fn next_https_bucket(len: usize) -> usize {
HTTPS_SIZE_BUCKETS
.iter()
.copied()
.find(|&b| b >= len)
.unwrap_or(len)
}
/// Best-effort random padding: append between `min_pad` and `max_pad` (inclusive) zero bytes to
/// `packet`, capping the result at `max_total` bytes so a hard size ceiling is never exceeded.
///
/// Signature rationale: callers want jitter on the wire without blowing a datagram budget, so the
/// knobs are an inclusive `[min_pad, max_pad]` range plus an absolute `max_total` clamp. Returns the
/// number of pad bytes actually appended (which may be **less** than `min_pad` if `max_total` was
/// already nearly reached, including `0` when `packet.len() >= max_total`).
///
/// "Best-effort" = if the requested padding does not fit under `max_total`, as much as fits is added
/// rather than erroring. If `min_pad > max_pad` the arguments are swapped so the call still does
/// something sensible instead of panicking.
pub fn inject_padding_frames(
packet: &mut Vec<u8>,
min_pad: usize,
max_pad: usize,
max_total: usize,
) -> usize {
let (lo, hi) = if min_pad <= max_pad {
(min_pad, max_pad)
} else {
(max_pad, min_pad)
};
let headroom = max_total.saturating_sub(packet.len());
if headroom == 0 {
return 0;
}
// Pick a random amount in [lo, hi], then clamp to whatever headroom remains.
let want = if lo == hi {
lo
} else {
rand::thread_rng().gen_range(lo..=hi)
};
let pad = want.min(headroom);
packet.resize(packet.len() + pad, 0);
pad
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pads_up_to_each_bucket() {
// One below / one into each bucket lands on that bucket.
let cases = [
(0usize, 64usize),
(1, 64),
(63, 64),
(65, 128),
(127, 128),
(200, 256),
(300, 512),
(513, 1024),
(1025, 1280),
(1281, 1460),
];
for (input, expected) in cases {
let mut v = vec![0xAB; input];
pad_to_https_size(&mut v);
assert_eq!(v.len(), expected, "padding {input} should reach {expected}");
}
}
#[test]
fn exact_bucket_is_unchanged_and_idempotent() {
for &b in &HTTPS_SIZE_BUCKETS {
let mut v = vec![1u8; b];
pad_to_https_size(&mut v);
assert_eq!(v.len(), b, "exact bucket {b} must not grow");
// Idempotence: padding again changes nothing.
pad_to_https_size(&mut v);
assert_eq!(v.len(), b, "re-padding bucket {b} must be a no-op");
}
}
#[test]
fn at_or_over_max_bucket_is_left_alone() {
for len in [MAX_BUCKET, MAX_BUCKET + 1, 2000, 9000] {
let mut v = vec![7u8; len];
pad_to_https_size(&mut v);
assert_eq!(v.len(), len, "len {len} >= MAX_BUCKET must be unchanged");
}
}
#[test]
fn padding_preserves_original_prefix() {
let mut v: Vec<u8> = (0..50u8).collect();
let original = v.clone();
pad_to_https_size(&mut v);
assert_eq!(v.len(), 64);
assert_eq!(&v[..50], &original[..], "real bytes must be preserved");
assert!(v[50..].iter().all(|&b| b == 0), "pad must be zero bytes");
}
#[test]
fn next_bucket_matches_padding() {
for len in [0usize, 1, 64, 65, 1024, 1459, 1460, 5000] {
let predicted = next_https_bucket(len);
let mut v = vec![0u8; len];
pad_to_https_size(&mut v);
assert_eq!(predicted, v.len(), "next_https_bucket disagrees at {len}");
}
}
#[test]
fn inject_padding_respects_range_and_cap() {
// Plenty of headroom: result grows by something in [4, 8].
let mut v = vec![0u8; 10];
let added = inject_padding_frames(&mut v, 4, 8, 1000);
assert!((4..=8).contains(&added), "added {added} outside [4,8]");
assert_eq!(v.len(), 10 + added);
}
#[test]
fn inject_padding_clamps_to_max_total() {
// Only 3 bytes of headroom even though we ask for 10..=10.
let mut v = vec![0u8; 97];
let added = inject_padding_frames(&mut v, 10, 10, 100);
assert_eq!(added, 3, "should add only what fits under max_total");
assert_eq!(v.len(), 100);
}
#[test]
fn inject_padding_zero_headroom_is_noop() {
let mut v = vec![0u8; 100];
let added = inject_padding_frames(&mut v, 1, 50, 100);
assert_eq!(added, 0);
assert_eq!(v.len(), 100);
// Already over the cap: still a no-op, never truncates.
let mut v2 = vec![0u8; 120];
let added2 = inject_padding_frames(&mut v2, 1, 50, 100);
assert_eq!(added2, 0);
assert_eq!(v2.len(), 120);
}
#[test]
fn inject_padding_swapped_bounds_dont_panic() {
let mut v = vec![0u8; 10];
let added = inject_padding_frames(&mut v, 8, 4, 1000); // min > max
assert!((4..=8).contains(&added));
}
}
+188
View File
@@ -0,0 +1,188 @@
//! quinn endpoint setup and the outer TLS configuration (project §7).
//!
//! This wires up the **outer** QUIC/TLS layer. Two things are deliberately unusual and security-
//! relevant:
//!
//! 1. The outer TLS uses ALPN `h3`/`h3-29` and Chrome-like transport params (see [`crate::mimicry`])
//! purely as camouflage.
//! 2. The QUIC **client accepts any server certificate** ([`AcceptAnyServerCert`]). This is safe
//! *only* because the outer TLS is not the authentication boundary: the real mutual auth is the
//! inner Aura proto handshake ([`aura_proto::client_handshake`] / [`aura_proto::server_handshake`])
//! run over the QUIC stream, which performs hybrid-PQ key agreement and mutual X.509 verification
//! against the Aura CA. Do not reuse `AcceptAnyServerCert` anywhere the TLS layer *is* the
//! authentication.
use std::sync::Arc;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use crate::mimicry::{alpn_protocols, chrome_quic_transport_config};
use crate::TransportError;
/// Ensure rustls 0.23 has a process-wide [`CryptoProvider`](rustls::crypto::CryptoProvider).
///
/// rustls 0.23 panics ("no process-level CryptoProvider available") if a config is built before a
/// default provider is installed. Installing is idempotent (a second install is ignored), so it is
/// safe — and cheap — to call this before building any client/server config. We use the `ring`
/// provider, matching quinn's `rustls-ring` default feature.
pub fn ensure_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
/// A [`ServerCertVerifier`] that accepts **any** server certificate without checking it.
///
/// See the module docs: the outer TLS is mimicry, not authentication, so the client does not (and
/// must not need to) trust the outer cert. All real verification is in the inner Aura handshake.
#[derive(Debug)]
pub struct AcceptAnyServerCert;
impl ServerCertVerifier for AcceptAnyServerCert {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
// Advertise the full modern set so we never reject the outer handshake for scheme reasons.
vec![
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::ECDSA_NISTP521_SHA512,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
]
}
}
/// Parse a PEM bundle of one or more certificates into DER.
pub(crate) fn certs_from_pem(pem: &str) -> Result<Vec<CertificateDer<'static>>, TransportError> {
let certs = rustls_pemfile::certs(&mut pem.as_bytes())
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TransportError::Pem(format!("parsing certificate PEM: {e}")))?;
if certs.is_empty() {
return Err(TransportError::Pem(
"certificate PEM contained no certificates".into(),
));
}
Ok(certs)
}
/// Parse a single PEM private key (PKCS#8 / SEC1 / PKCS#1) into DER.
pub(crate) fn key_from_pem(pem: &str) -> Result<PrivateKeyDer<'static>, TransportError> {
rustls_pemfile::private_key(&mut pem.as_bytes())
.map_err(|e| TransportError::Pem(format!("parsing private key PEM: {e}")))?
.ok_or_else(|| TransportError::Pem("private key PEM contained no key".into()))
}
/// Build the outer-TLS `quinn::ServerConfig` from a cert chain PEM and key PEM.
///
/// The cert here is only the *outer* (mimicry) certificate; it may be the same PEM as the Aura
/// server cert. Client auth is disabled at this (outer) layer because mutual auth is done in the
/// inner handshake.
pub fn server_quic_config(
cert_pem: &str,
key_pem: &str,
) -> Result<quinn::ServerConfig, TransportError> {
ensure_crypto_provider();
let certs = certs_from_pem(cert_pem)?;
let key = key_from_pem(key_pem)?;
let mut sc = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| TransportError::Tls(format!("building server TLS config: {e}")))?;
sc.alpn_protocols = alpn_protocols();
let qsc = quinn::crypto::rustls::QuicServerConfig::try_from(sc)
.map_err(|e| TransportError::Tls(format!("rustls->quic server config: {e}")))?;
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(qsc));
server_config.transport_config(Arc::new(chrome_quic_transport_config()));
Ok(server_config)
}
/// Build the outer-TLS `quinn::ClientConfig` (with the dangerous accept-any verifier).
pub fn client_quic_config() -> Result<quinn::ClientConfig, TransportError> {
ensure_crypto_provider();
let mut cc = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert))
.with_no_client_auth();
cc.alpn_protocols = alpn_protocols();
let qcc = quinn::crypto::rustls::QuicClientConfig::try_from(cc)
.map_err(|e| TransportError::Tls(format!("rustls->quic client config: {e}")))?;
let mut client_config = quinn::ClientConfig::new(Arc::new(qcc));
client_config.transport_config(Arc::new(chrome_quic_transport_config()));
Ok(client_config)
}
/// Build a bound server [`quinn::Endpoint`] listening on `addr` (use `127.0.0.1:0` for an
/// OS-assigned port, then read it back with [`quinn::Endpoint::local_addr`]).
pub fn server_endpoint(
addr: std::net::SocketAddr,
cert_pem: &str,
key_pem: &str,
) -> Result<quinn::Endpoint, TransportError> {
let config = server_quic_config(cert_pem, key_pem)?;
let endpoint = quinn::Endpoint::server(config, addr)?;
Ok(endpoint)
}
/// Build a client [`quinn::Endpoint`] bound to an ephemeral local UDP port with the outer-TLS
/// client config installed as default.
pub fn client_endpoint() -> Result<quinn::Endpoint, TransportError> {
let config = client_quic_config()?;
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().expect("valid bind addr"))?;
endpoint.set_default_client_config(config);
Ok(endpoint)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_config_builds() {
// Exercises the crypto-provider install + dangerous verifier wiring (§7 client recipe).
client_quic_config().expect("client config should build");
}
#[test]
fn rejects_empty_cert_pem() {
let err = certs_from_pem("not a pem").unwrap_err();
assert!(matches!(err, TransportError::Pem(_)));
}
}
+124
View File
@@ -0,0 +1,124 @@
//! End-to-end integration test over genuine QUIC on loopback.
//!
//! Proves that aura-crypto + aura-pki + aura-proto + aura-transport integrate: we mint a real CA,
//! issue a server cert (SAN "localhost") and a client cert, bind an [`AuraServer`] on an
//! OS-assigned loopback port, connect an [`AuraClient`] (with a camouflage SNI distinct from the
//! verified server name), run accept + connect concurrently, then push packets both directions
//! through the [`PacketConnection`] API and assert payload integrity.
use std::sync::Arc;
use aura_pki::AuraCa;
use aura_proto::{ClientConfig, PacketConnection, ServerConfig};
use aura_transport::{AuraClient, AuraConnection, AuraServer};
/// The DNS name baked into the server cert SAN and verified by the inner Aura handshake.
const SERVER_NAME: &str = "localhost";
/// A deliberately different outer SNI, to prove the mimicry hostname is independent of the
/// inner-verified server name.
const CAMOUFLAGE_SNI: &str = "cdn.example.com";
#[tokio::test]
async fn end_to_end_quic_loopback() {
// --- PKI: CA + server cert + client cert ------------------------------------------------
let ca = AuraCa::generate("Aura Test CA").expect("generate CA");
let server_cert = ca
.issue_server_cert(SERVER_NAME)
.expect("issue server cert");
let client_cert = ca
.issue_client_cert("client-001")
.expect("issue client cert");
let ca_pem = ca.ca_cert_pem();
let server_cfg = ServerConfig {
ca_cert_pem: ca_pem.clone(),
server_cert_pem: server_cert.cert_pem.clone(),
server_key_pem: server_cert.key_pem.clone(),
};
let client_cfg = ClientConfig {
ca_cert_pem: ca_pem.clone(),
client_cert_pem: client_cert.cert_pem.clone(),
client_key_pem: client_cert.key_pem.clone(),
server_name: SERVER_NAME.to_string(),
};
// --- Bind the server on 127.0.0.1:0 and read the OS-assigned port -----------------------
// The outer QUIC (mimicry) cert reuses the Aura server cert PEM, as the brief suggests.
let server = AuraServer::bind(
"127.0.0.1:0".parse().unwrap(),
&server_cert.cert_pem,
&server_cert.key_pem,
server_cfg,
)
.expect("bind server");
let server_addr = server.local_addr().expect("server local_addr");
assert_ne!(server_addr.port(), 0, "OS should assign a real port");
// --- Run accept + connect concurrently --------------------------------------------------
let accept_task = tokio::spawn(async move { server.accept().await });
let connect_task =
tokio::spawn(
async move { AuraClient::connect(server_addr, CAMOUFLAGE_SNI, client_cfg).await },
);
let server_conn: AuraConnection = accept_task
.await
.expect("accept task join")
.expect("server accept");
let client_conn: AuraConnection = connect_task
.await
.expect("connect task join")
.expect("client connect");
// The mutual-auth handshake should have established peer identities both ways.
assert_eq!(
server_conn.peer_id(),
Some("client-001"),
"server should learn the client's verified CN"
);
// Share both ends as trait objects, proving `Arc<dyn PacketConnection>` usability.
let server_conn: Arc<dyn PacketConnection> = Arc::new(server_conn);
let client_conn: Arc<dyn PacketConnection> = Arc::new(client_conn);
// --- Client -> Server: several packets, assert integrity --------------------------------
let c2s: Vec<Vec<u8>> = vec![
b"hello server".to_vec(),
vec![0u8; 1500], // larger-than-bucket payload
(0..=255u8).collect(), // every byte value
b"".to_vec(), // empty packet
];
for pkt in &c2s {
client_conn.send_packet(pkt).await.expect("client send");
let got = server_conn.recv_packet().await.expect("server recv");
assert_eq!(&got, pkt, "client->server payload mismatch");
}
// --- Server -> Client: several packets, assert integrity --------------------------------
let s2c: Vec<Vec<u8>> = vec![
b"hello client".to_vec(),
vec![0xABu8; 777],
b"final packet".to_vec(),
];
for pkt in &s2c {
server_conn.send_packet(pkt).await.expect("server send");
let got = client_conn.recv_packet().await.expect("client recv");
assert_eq!(&got, pkt, "server->client payload mismatch");
}
// --- Concurrent full-duplex: both directions in flight at once --------------------------
let s = server_conn.clone();
let c = client_conn.clone();
let dup_server = tokio::spawn(async move {
s.send_packet(b"duplex-from-server").await.unwrap();
s.recv_packet().await.unwrap()
});
let dup_client = tokio::spawn(async move {
c.send_packet(b"duplex-from-client").await.unwrap();
c.recv_packet().await.unwrap()
});
let server_got = dup_server.await.expect("dup server join");
let client_got = dup_client.await.expect("dup client join");
assert_eq!(server_got, b"duplex-from-client");
assert_eq!(client_got, b"duplex-from-server");
}
+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()))
}
}
+288
View File
@@ -0,0 +1,288 @@
//! Deterministic tests for the tunnel data plane: routing-table classification, the `dst_ip`
//! parser, DNS host-route registration (no live query), and the router run-loop driven by a mock
//! [`PacketConnection`] and a mock TUN. None of these touch the network or require root.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use async_trait::async_trait;
use aura_proto::PacketConnection;
use aura_tunnel::router::dst_ip;
use aura_tunnel::tun::PacketIo;
use aura_tunnel::{AuraDns, AuraRouter, RouteAction, RouteTable};
use tokio::sync::{mpsc, RwLock};
// ---- §8.4 RouteTable classification --------------------------------------------------------------
/// `192.168.1.1` matches the `192.168.0.0/16 -> Direct` rule under a `Vpn` default.
#[test]
fn test_route_classify_cidr() {
let mut table = RouteTable::new(RouteAction::Vpn);
table.add_cidr("192.168.0.0/16".parse().unwrap(), RouteAction::Direct);
let ip: IpAddr = "192.168.1.1".parse().unwrap();
assert_eq!(table.classify(ip), RouteAction::Direct);
}
/// `8.8.8.8` matches no rule and falls through to the `Vpn` default.
#[test]
fn test_route_classify_vpn() {
let mut table = RouteTable::new(RouteAction::Vpn);
table.add_cidr("192.168.0.0/16".parse().unwrap(), RouteAction::Direct);
let ip: IpAddr = "8.8.8.8".parse().unwrap();
assert_eq!(table.classify(ip), RouteAction::Vpn);
}
/// Longest-prefix wins: a more specific `/24 -> Vpn` overrides a less specific `/16 -> Direct`.
#[test]
fn test_route_priority() {
let mut table = RouteTable::new(RouteAction::Direct);
table.add_cidr("10.0.0.0/8".parse().unwrap(), RouteAction::Direct);
table.add_cidr("10.1.2.0/24".parse().unwrap(), RouteAction::Vpn);
// Inside the /24 -> the most specific rule (Vpn) wins.
assert_eq!(
table.classify("10.1.2.5".parse().unwrap()),
RouteAction::Vpn
);
// Inside the /8 but outside the /24 -> the /8 rule (Direct) applies.
assert_eq!(
table.classify("10.9.9.9".parse().unwrap()),
RouteAction::Direct
);
// Outside both -> default (Direct).
assert_eq!(
table.classify("8.8.8.8".parse().unwrap()),
RouteAction::Direct
);
}
/// A host route (`/32`) is the most specific possible match and overrides any broader rule.
#[test]
fn test_route_host_route_overrides() {
let mut table = RouteTable::new(RouteAction::Vpn);
table.add_cidr("0.0.0.0/0".parse().unwrap(), RouteAction::Vpn);
table.add_host_route("1.2.3.4".parse().unwrap(), RouteAction::Direct);
assert_eq!(
table.classify("1.2.3.4".parse().unwrap()),
RouteAction::Direct
);
assert_eq!(table.classify("1.2.3.5".parse().unwrap()), RouteAction::Vpn);
}
// ---- dst_ip parser ------------------------------------------------------------------------------
/// IPv4 header: version nibble 4, destination at bytes 16..20.
#[test]
fn test_dst_ip_v4() {
let mut pkt = [0u8; 20];
pkt[0] = 0x45; // version 4, IHL 5
pkt[16] = 8;
pkt[17] = 8;
pkt[18] = 4;
pkt[19] = 4;
assert_eq!(dst_ip(&pkt), Some(IpAddr::V4(Ipv4Addr::new(8, 8, 4, 4))));
}
/// IPv6 header: version nibble 6, destination at bytes 24..40.
#[test]
fn test_dst_ip_v6() {
let mut pkt = [0u8; 40];
pkt[0] = 0x60; // version 6
let dst = Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888);
pkt[24..40].copy_from_slice(&dst.octets());
assert_eq!(dst_ip(&pkt), Some(IpAddr::V6(dst)));
}
/// Too-short and unknown-version packets parse to `None`.
#[test]
fn test_dst_ip_invalid() {
assert_eq!(dst_ip(&[]), None);
assert_eq!(dst_ip(&[0x45, 0, 0]), None); // v4 but truncated
let short_v6 = [0x60u8; 39];
assert_eq!(dst_ip(&short_v6), None); // v6 but truncated
let weird = [0x35u8; 64];
assert_eq!(dst_ip(&weird), None); // version nibble 3
}
// ---- §8.5 AuraDns::register_ips (no live query) -------------------------------------------------
/// `register_ips` inserts each address as a host route in the shared table and caches the set —
/// validated without any DNS query.
#[tokio::test]
async fn test_dns_register_ips_no_query() {
let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn)));
let mut dns = AuraDns::new(Arc::clone(&routes)).await.unwrap();
let ips = vec![
IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)),
IpAddr::V6(Ipv6Addr::new(
0x2606, 0x2800, 0x220, 1, 0x248, 0x1893, 0x25c8, 0x1946,
)),
];
dns.register_ips("example.com", &ips, RouteAction::Direct)
.await;
// Both addresses now classify as Direct host routes.
let table = routes.read().await;
assert_eq!(table.classify(ips[0]), RouteAction::Direct);
assert_eq!(table.classify(ips[1]), RouteAction::Direct);
drop(table);
// And the resolution is cached.
assert_eq!(dns.cached("example.com"), Some(ips.as_slice()));
}
// ---- §8.6 AuraRouter run-loop with mock PacketConnection + mock TUN -----------------------------
/// In-memory fake TUN: `read_packet` drains an injected queue (and parks when empty), `write_packet`
/// forwards to a channel the test observes.
struct MockTun {
inbound: mpsc::Receiver<Vec<u8>>,
written: mpsc::Sender<Vec<u8>>,
}
#[async_trait]
impl PacketIo for MockTun {
async fn read_packet(&mut self) -> std::io::Result<Vec<u8>> {
match self.inbound.recv().await {
Some(pkt) => Ok(pkt),
// Channel closed: surface EOF so the router can stop cleanly.
None => Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"mock TUN closed",
)),
}
}
async fn write_packet(&mut self, packet: &[u8]) -> std::io::Result<()> {
self.written
.send(packet.to_vec())
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test dropped"))
}
}
/// Mock encrypted connection backed by mpsc: `send_packet` forwards to a channel the test reads;
/// `recv_packet` drains a channel the test feeds.
struct MockConn {
sent: mpsc::Sender<Vec<u8>>,
to_recv: tokio::sync::Mutex<mpsc::Receiver<Vec<u8>>>,
}
#[async_trait]
impl PacketConnection for MockConn {
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
self.sent.send(packet.to_vec()).await?;
Ok(())
}
async fn recv_packet(&self) -> anyhow::Result<Vec<u8>> {
let mut rx = self.to_recv.lock().await;
match rx.recv().await {
Some(pkt) => Ok(pkt),
None => Err(anyhow::anyhow!("mock conn closed")),
}
}
}
/// Build a minimal valid IPv4 packet whose destination is `dst`.
fn ipv4_packet_to(dst: Ipv4Addr) -> Vec<u8> {
let mut pkt = vec![0u8; 20];
pkt[0] = 0x45;
let o = dst.octets();
pkt[16..20].copy_from_slice(&o);
pkt
}
#[tokio::test]
async fn test_router_vpn_outbound_and_inbound() {
// Channels wiring the mocks to the test.
let (tun_in_tx, tun_in_rx) = mpsc::channel::<Vec<u8>>(8); // test -> TUN read
let (tun_out_tx, mut tun_out_rx) = mpsc::channel::<Vec<u8>>(8); // TUN write -> test
let (conn_sent_tx, mut conn_sent_rx) = mpsc::channel::<Vec<u8>>(8); // conn.send -> test
let (conn_recv_tx, conn_recv_rx) = mpsc::channel::<Vec<u8>>(8); // test -> conn.recv
let tun = MockTun {
inbound: tun_in_rx,
written: tun_out_tx,
};
let conn: Arc<dyn PacketConnection> = Arc::new(MockConn {
sent: conn_sent_tx,
to_recv: tokio::sync::Mutex::new(conn_recv_rx),
});
// Default Vpn so a packet to 8.8.8.8 is routed through the connection.
let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn)));
let router = AuraRouter::new(tun, Arc::clone(&routes), conn);
let handle = tokio::spawn(router.run());
// (a) Outbound: emit a packet to 8.8.8.8 from the TUN -> it must reach the connection.
let out_pkt = ipv4_packet_to(Ipv4Addr::new(8, 8, 8, 8));
tun_in_tx.send(out_pkt.clone()).await.unwrap();
let got = tokio::time::timeout(std::time::Duration::from_secs(2), conn_sent_rx.recv())
.await
.expect("router did not forward outbound packet to connection in time")
.expect("connection sent channel closed");
assert_eq!(got, out_pkt, "VPN-routed packet should be sent verbatim");
// (b) Inbound: feed a packet into the connection -> it must be written to the TUN.
let in_pkt = ipv4_packet_to(Ipv4Addr::new(10, 0, 0, 9));
conn_recv_tx.send(in_pkt.clone()).await.unwrap();
let written = tokio::time::timeout(std::time::Duration::from_secs(2), tun_out_rx.recv())
.await
.expect("router did not write inbound packet to TUN in time")
.expect("TUN write channel closed");
assert_eq!(written, in_pkt, "inbound packet should be written verbatim");
// Shut the router down by closing the TUN read channel.
drop(tun_in_tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
/// A Direct-routed outbound packet must NOT be forwarded to the VPN connection (it goes to the v1
/// `send_direct` stub instead).
#[tokio::test]
async fn test_router_direct_not_sent_to_vpn() {
let (tun_in_tx, tun_in_rx) = mpsc::channel::<Vec<u8>>(8);
let (tun_out_tx, _tun_out_rx) = mpsc::channel::<Vec<u8>>(8);
let (conn_sent_tx, mut conn_sent_rx) = mpsc::channel::<Vec<u8>>(8);
let (_conn_recv_tx, conn_recv_rx) = mpsc::channel::<Vec<u8>>(8);
let tun = MockTun {
inbound: tun_in_rx,
written: tun_out_tx,
};
let conn: Arc<dyn PacketConnection> = Arc::new(MockConn {
sent: conn_sent_tx,
to_recv: tokio::sync::Mutex::new(conn_recv_rx),
});
// Default Vpn, but a /16 -> Direct rule makes 192.168.x.x bypass the tunnel.
let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn)));
routes
.write()
.await
.add_cidr("192.168.0.0/16".parse().unwrap(), RouteAction::Direct);
let router = AuraRouter::new(tun, routes, conn);
let handle = tokio::spawn(router.run());
tun_in_tx
.send(ipv4_packet_to(Ipv4Addr::new(192, 168, 1, 1)))
.await
.unwrap();
// The connection must receive nothing within a short window.
let res =
tokio::time::timeout(std::time::Duration::from_millis(300), conn_sent_rx.recv()).await;
assert!(
res.is_err(),
"Direct-routed packet must not be sent to the VPN connection"
);
drop(tun_in_tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}