From c19a6c558613b169701018ee105e96502c8eb690 Mon Sep 17 00:00:00 2001 From: xah30 Date: Mon, 25 May 2026 18:26:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(transport,tunnel):=20implement=20Wave=203?= =?UTF-8?q?=20=E2=80=94=20QUIC=20transport=20+=20split-tunnel=20router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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 --- Cargo.lock | 11 + crates/aura-transport/Cargo.toml | 8 + crates/aura-transport/src/conn.rs | 103 +++++++++ crates/aura-transport/src/lib.rs | 224 +++++++++++++++++- crates/aura-transport/src/mimicry.rs | 87 +++++++ crates/aura-transport/src/padding.rs | 204 +++++++++++++++++ crates/aura-transport/src/quic.rs | 188 ++++++++++++++++ crates/aura-transport/tests/loopback.rs | 124 ++++++++++ crates/aura-tunnel/src/dns.rs | 102 +++++++++ crates/aura-tunnel/src/lib.rs | 87 ++++++- crates/aura-tunnel/src/router.rs | 151 +++++++++++++ crates/aura-tunnel/src/routes.rs | 108 +++++++++ crates/aura-tunnel/src/tun.rs | 206 +++++++++++++++++ crates/aura-tunnel/tests/routes.rs | 288 ++++++++++++++++++++++++ 14 files changed, 1887 insertions(+), 4 deletions(-) create mode 100644 crates/aura-transport/src/conn.rs create mode 100644 crates/aura-transport/src/mimicry.rs create mode 100644 crates/aura-transport/src/padding.rs create mode 100644 crates/aura-transport/src/quic.rs create mode 100644 crates/aura-transport/tests/loopback.rs create mode 100644 crates/aura-tunnel/src/dns.rs create mode 100644 crates/aura-tunnel/src/router.rs create mode 100644 crates/aura-tunnel/src/routes.rs create mode 100644 crates/aura-tunnel/src/tun.rs create mode 100644 crates/aura-tunnel/tests/routes.rs diff --git a/Cargo.lock b/Cargo.lock index 17915c9..27f1265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,11 +267,13 @@ dependencies = [ "anyhow", "async-trait", "aura-crypto", + "aura-pki", "aura-proto", "bytes", "quinn", "rand 0.8.6", "rustls", + "rustls-pemfile", "rustls-pki-types", "thiserror 1.0.69", "tokio", @@ -2205,6 +2207,15 @@ dependencies = [ "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]] name = "rustls-pki-types" version = "1.14.1" diff --git a/crates/aura-transport/Cargo.toml b/crates/aura-transport/Cargo.toml index 4ca9a07..f41d089 100644 --- a/crates/aura-transport/Cargo.toml +++ b/crates/aura-transport/Cargo.toml @@ -18,3 +18,11 @@ tracing.workspace = true thiserror.workspace = true anyhow.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 diff --git a/crates/aura-transport/src/conn.rs b/crates/aura-transport/src/conn.rs new file mode 100644 index 0000000..21542ed --- /dev/null +++ b/crates/aura-transport/src/conn.rs @@ -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; + +/// 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` 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>, + receiver: Mutex>, + /// 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, +} + +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 { + 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> { + // 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}"); + } + } + } + } +} diff --git a/crates/aura-transport/src/lib.rs b/crates/aura-transport/src/lib.rs index 552888c..aafd0ec 100644 --- a/crates/aura-transport/src/lib.rs +++ b/crates/aura-transport/src/lib.rs @@ -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 -//! the quinn endpoint setup (`quic`), mimicry (`mimicry`), and packet padding (`padding`). +//! This crate carries the Aura protocol over real QUIC and exposes an established connection as an +//! [`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). +//! 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` 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 for TransportError { + fn from(e: quinn::ConnectError) -> Self { + TransportError::Quic(format!("connect: {e}")) + } +} +impl From 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, +} + +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 { + 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 { + 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 { + 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 { + 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)) + } +} diff --git a/crates/aura-transport/src/mimicry.rs b/crates/aura-transport/src/mimicry.rs new file mode 100644 index 0000000..50c7cd9 --- /dev/null +++ b/crates/aura-transport/src/mimicry.rs @@ -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>`, the shape rustls' `alpn_protocols` field wants. +#[must_use] +pub fn alpn_protocols() -> Vec> { + 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(); + } +} diff --git a/crates/aura-transport/src/padding.rs b/crates/aura-transport/src/padding.rs new file mode 100644 index 0000000..e9233bc --- /dev/null +++ b/crates/aura-transport/src/padding.rs @@ -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) { + 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, + 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 = (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)); + } +} diff --git a/crates/aura-transport/src/quic.rs b/crates/aura-transport/src/quic.rs new file mode 100644 index 0000000..e84fb60 --- /dev/null +++ b/crates/aura-transport/src/quic.rs @@ -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 { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + // 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>, TransportError> { + let certs = rustls_pemfile::certs(&mut pem.as_bytes()) + .collect::, _>>() + .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, 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 { + 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 { + 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 { + 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 { + 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(_))); + } +} diff --git a/crates/aura-transport/tests/loopback.rs b/crates/aura-transport/tests/loopback.rs new file mode 100644 index 0000000..5550636 --- /dev/null +++ b/crates/aura-transport/tests/loopback.rs @@ -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` usability. + let server_conn: Arc = Arc::new(server_conn); + let client_conn: Arc = Arc::new(client_conn); + + // --- Client -> Server: several packets, assert integrity -------------------------------- + let c2s: Vec> = 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![ + 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"); +} diff --git a/crates/aura-tunnel/src/dns.rs b/crates/aura-tunnel/src/dns.rs new file mode 100644 index 0000000..9b555cf --- /dev/null +++ b/crates/aura-tunnel/src/dns.rs @@ -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>`. [`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>, + routes: Arc>, +} + +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>) -> anyhow::Result { + 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>) -> Self { + Self { + resolver, + cache: HashMap::new(), + routes, + } + } + + /// The shared routing table this resolver registers into. + pub fn routes(&self) -> &Arc> { + &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> { + let lookup = self.resolver.lookup_ip(domain).await?; + let ips: Vec = 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" + ); + } +} diff --git a/crates/aura-tunnel/src/lib.rs b/crates/aura-tunnel/src/lib.rs index 3d3637a..32b5b9e 100644 --- a/crates/aura-tunnel/src/lib.rs +++ b/crates/aura-tunnel/src/lib.rs @@ -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) -> 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), +} diff --git a/crates/aura-tunnel/src/router.rs b/crates/aura-tunnel/src/router.rs new file mode 100644 index 0000000..31b79da --- /dev/null +++ b/crates/aura-tunnel/src/router.rs @@ -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>`, and an `Arc`. +//! +//! [`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 { + 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 { + tun: P, + routes: Arc>, + conn: Arc, +} + +impl AuraRouter

{ + /// 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>, conn: Arc) -> 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::>(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(()) + } +} diff --git a/crates/aura-tunnel/src/routes.rs b/crates/aura-tunnel/src/routes.rs new file mode 100644 index 0000000..0338c2a --- /dev/null +++ b/crates/aura-tunnel/src/routes.rs @@ -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>` 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, + /// 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, + /// 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 { + 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) + } +} diff --git a/crates/aura-tunnel/src/tun.rs b/crates/aura-tunnel/src/tun.rs new file mode 100644 index 0000000..e927ffc --- /dev/null +++ b/crates/aura-tunnel/src/tun.rs @@ -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>; + /// 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, + #[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 { + 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> { + // 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 { + 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> { + 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> { + 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())) + } +} diff --git a/crates/aura-tunnel/tests/routes.rs b/crates/aura-tunnel/tests/routes.rs new file mode 100644 index 0000000..4b5ae8f --- /dev/null +++ b/crates/aura-tunnel/tests/routes.rs @@ -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>, + written: mpsc::Sender>, +} + +#[async_trait] +impl PacketIo for MockTun { + async fn read_packet(&mut self) -> std::io::Result> { + 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>, + to_recv: tokio::sync::Mutex>>, +} + +#[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> { + 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 { + 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::>(8); // test -> TUN read + let (tun_out_tx, mut tun_out_rx) = mpsc::channel::>(8); // TUN write -> test + let (conn_sent_tx, mut conn_sent_rx) = mpsc::channel::>(8); // conn.send -> test + let (conn_recv_tx, conn_recv_rx) = mpsc::channel::>(8); // test -> conn.recv + + let tun = MockTun { + inbound: tun_in_rx, + written: tun_out_tx, + }; + let conn: Arc = 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::>(8); + let (tun_out_tx, _tun_out_rx) = mpsc::channel::>(8); + let (conn_sent_tx, mut conn_sent_rx) = mpsc::channel::>(8); + let (_conn_recv_tx, conn_recv_rx) = mpsc::channel::>(8); + + let tun = MockTun { + inbound: tun_in_rx, + written: tun_out_tx, + }; + let conn: Arc = 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; +}