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