From d72fbe8d6862221a59bef9917c257d795ee90f39 Mon Sep 17 00:00:00 2001 From: xah30 Date: Mon, 25 May 2026 19:15:31 +0300 Subject: [PATCH] feat(transport): TCP/443 fallback + unified dialer with UDP->TCP->QUIC handover - tcp.rs: Aura proto handshake + Session directly over TcpStream (TcpServer/ TcpClient/TcpConnection: PacketConnection), with an optional light HTTP/1.1 masquerade preamble. Fallback for UDP-blocking networks. (Full TLS-443 mimicry is a documented follow-up.) - dial.rs: TransportMode {Udp,Tcp,Quic}, Endpoints, DialConfig; client `dial()` tries transports in order and hands over on failure/timeout; MultiServer binds and accepts on every enabled transport at once (TCP/QUIC multi-client; UDP single-peer-per-accept in v1). - Tests: tcp loopback (plain + masquerade), dial handover (dead TCP -> UDP). clippy/fmt clean. Co-Authored-By: Claude Opus 4.7 --- crates/aura-transport/src/dial.rs | 297 +++++++++++++++++++ crates/aura-transport/src/lib.rs | 4 + crates/aura-transport/src/tcp.rs | 256 ++++++++++++++++ crates/aura-transport/tests/dial_fallback.rs | 74 +++++ crates/aura-transport/tests/tcp_loopback.rs | 72 +++++ 5 files changed, 703 insertions(+) create mode 100644 crates/aura-transport/src/dial.rs create mode 100644 crates/aura-transport/src/tcp.rs create mode 100644 crates/aura-transport/tests/dial_fallback.rs create mode 100644 crates/aura-transport/tests/tcp_loopback.rs diff --git a/crates/aura-transport/src/dial.rs b/crates/aura-transport/src/dial.rs new file mode 100644 index 0000000..3cbf9eb --- /dev/null +++ b/crates/aura-transport/src/dial.rs @@ -0,0 +1,297 @@ +//! Unified transport selection: a client [`dial`] that tries transports in order (UDP → TCP → +//! QUIC, the "handover") and a [`MultiServer`] that accepts on every enabled transport at once. +//! +//! All three backends produce an `Arc`, so the tunnel router does +//! not care which transport carried a connection. The primary path is Aura's own UDP transport; +//! TCP/443 and QUIC are fallbacks for networks that throttle or block plain UDP. + +use std::fmt; +use std::net::SocketAddr; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +use aura_proto::{ClientConfig, PacketConnection, ServerConfig}; +use tokio::sync::mpsc; + +use crate::{AuraClient, AuraServer, TcpClient, TcpOpts, TcpServer, UdpClient, UdpOpts, UdpServer}; + +/// Which wire transport carries an Aura connection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransportMode { + /// Aura's own protocol over plain UDP (primary). + Udp, + /// Aura over TCP (fallback for UDP-blocking networks; optional HTTP masquerade). + Tcp, + /// Aura inside QUIC/HTTP3 mimicry (fallback / strong camouflage). + Quic, +} + +impl FromStr for TransportMode { + type Err = String; + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "udp" => Ok(Self::Udp), + "tcp" => Ok(Self::Tcp), + "quic" => Ok(Self::Quic), + other => Err(format!("unknown transport '{other}' (expected udp|tcp|quic)")), + } + } +} + +impl fmt::Display for TransportMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Udp => "udp", + Self::Tcp => "tcp", + Self::Quic => "quic", + }) + } +} + +/// Per-transport server addresses. Any subset may be set; `None` means that transport is disabled. +/// +/// The UDP transport and QUIC both use UDP and therefore must use *different* ports; TCP can share a +/// port number with the UDP transport (different protocol). +#[derive(Clone, Debug, Default)] +pub struct Endpoints { + /// Address of the custom-UDP transport. + pub udp: Option, + /// Address of the TCP transport. + pub tcp: Option, + /// Address of the QUIC transport. + pub quic: Option, +} + +/// Client-side dial configuration: where the server is per transport, the fallback order, and +/// per-transport options. +#[derive(Clone, Debug)] +pub struct DialConfig { + /// Server addresses per transport. + pub endpoints: Endpoints, + /// SNI / masquerade hostname (QUIC outer SNI; TCP masquerade Host). + pub sni: String, + /// Transports to try, in order. The first that connects wins. + pub order: Vec, + /// Options for the UDP transport. + pub udp: UdpOpts, + /// Options for the TCP transport. + pub tcp: TcpOpts, + /// Per-attempt timeout before moving to the next transport in `order`. + pub attempt_timeout: Duration, +} + +impl Default for DialConfig { + fn default() -> Self { + Self { + endpoints: Endpoints::default(), + sni: "cdn.example.com".to_string(), + order: vec![TransportMode::Udp, TransportMode::Tcp, TransportMode::Quic], + udp: UdpOpts::default(), + tcp: TcpOpts::default(), + attempt_timeout: Duration::from_secs(8), + } + } +} + +/// Connect to the server, trying each transport in `cfg.order` until one succeeds ("handover"). +/// +/// Returns the established connection and which transport carried it. A transport with no configured +/// address is skipped; a transport that errors or times out moves on to the next. +/// +/// # Errors +/// Returns the last error if every configured transport fails (or an error if none were configured). +pub async fn dial( + proto_cfg: ClientConfig, + cfg: DialConfig, +) -> anyhow::Result<(Arc, TransportMode)> { + let mut last_err: Option = None; + for mode in &cfg.order { + let addr = match mode { + TransportMode::Udp => cfg.endpoints.udp, + TransportMode::Tcp => cfg.endpoints.tcp, + TransportMode::Quic => cfg.endpoints.quic, + }; + let Some(addr) = addr else { + continue; // transport not configured + }; + tracing::info!("dial: trying {mode} at {addr}"); + let attempt = + tokio::time::timeout(cfg.attempt_timeout, dial_one(*mode, addr, &proto_cfg, &cfg)).await; + match attempt { + Ok(Ok(conn)) => { + tracing::info!("dial: connected via {mode}"); + return Ok((conn, *mode)); + } + Ok(Err(e)) => { + tracing::warn!("dial: {mode} failed: {e:#}"); + last_err = Some(e); + } + Err(_) => { + tracing::warn!("dial: {mode} timed out after {:?}", cfg.attempt_timeout); + last_err = Some(anyhow::anyhow!("{mode} connect timed out")); + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no transports configured to dial"))) +} + +async fn dial_one( + mode: TransportMode, + addr: SocketAddr, + proto_cfg: &ClientConfig, + cfg: &DialConfig, +) -> anyhow::Result> { + Ok(match mode { + TransportMode::Udp => UdpClient::connect(addr, proto_cfg.clone(), cfg.udp) + .await? + .into_dyn(), + TransportMode::Tcp => TcpClient::connect(addr, proto_cfg.clone(), cfg.tcp.clone()) + .await? + .into_dyn(), + TransportMode::Quic => AuraClient::connect(addr, &cfg.sni, proto_cfg.clone()) + .await? + .into_dyn(), + }) +} + +/// An accepted connection plus which transport carried it and the verified peer id. +pub struct Accepted { + /// The established packet pipe. + pub conn: Arc, + /// Which transport accepted it. + pub mode: TransportMode, + /// Verified peer Common Name (client id), if any. + pub peer_id: Option, +} + +/// A server that listens on every enabled transport simultaneously and yields accepted connections +/// from all of them through one [`MultiServer::accept`] call. +/// +/// TCP and QUIC accept loops handle many clients. The custom-UDP backend is single-peer-per-accept +/// in v1 (a multi-client UDP demux is a documented follow-up), so with several clients prefer TCP or +/// QUIC, or run one UDP server per client. +pub struct MultiServer { + rx: mpsc::Receiver, + tasks: Vec>, +} + +impl MultiServer { + /// Bind and start accept loops for every transport whose address is set in `endpoints`. + /// The QUIC outer-TLS cert reuses the Aura server cert from `proto_cfg`. + /// + /// # Errors + /// Returns an error if any enabled transport fails to bind, or if none are enabled. + pub async fn bind( + endpoints: Endpoints, + proto_cfg: ServerConfig, + udp: UdpOpts, + tcp: TcpOpts, + ) -> anyhow::Result { + let (txc, rx) = mpsc::channel::(32); + let mut tasks = Vec::new(); + + if let Some(addr) = endpoints.udp { + let server = UdpServer::bind(addr, proto_cfg.clone(), udp)?; + tasks.push(tokio::spawn(udp_accept_loop(server, txc.clone()))); + } + if let Some(addr) = endpoints.tcp { + let server = TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?; + tasks.push(tokio::spawn(tcp_accept_loop(server, txc.clone()))); + } + if let Some(addr) = endpoints.quic { + let server = AuraServer::bind( + addr, + &proto_cfg.server_cert_pem, + &proto_cfg.server_key_pem, + proto_cfg.clone(), + )?; + tasks.push(tokio::spawn(quic_accept_loop(server, txc.clone()))); + } + + if tasks.is_empty() { + anyhow::bail!("MultiServer: no transports enabled"); + } + Ok(Self { rx, tasks }) + } + + /// Wait for the next accepted connection from any enabled transport. Returns `None` when all + /// accept loops have stopped. + pub async fn accept(&mut self) -> Option { + self.rx.recv().await + } +} + +impl Drop for MultiServer { + fn drop(&mut self) { + for t in &self.tasks { + t.abort(); + } + } +} + +async fn udp_accept_loop(server: UdpServer, tx: mpsc::Sender) { + loop { + match server.accept().await { + Ok(conn) => { + let peer_id = conn.peer_id().map(str::to_owned); + let accepted = Accepted { + conn: conn.into_dyn(), + mode: TransportMode::Udp, + peer_id, + }; + if tx.send(accepted).await.is_err() { + break; + } + } + Err(e) => { + tracing::warn!("udp accept failed: {e:#}"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + } +} + +async fn tcp_accept_loop(server: TcpServer, tx: mpsc::Sender) { + loop { + match server.accept().await { + Ok(conn) => { + let peer_id = conn.peer_id().map(str::to_owned); + let accepted = Accepted { + conn: conn.into_dyn(), + mode: TransportMode::Tcp, + peer_id, + }; + if tx.send(accepted).await.is_err() { + break; + } + } + Err(e) => { + tracing::warn!("tcp accept failed: {e:#}"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + } +} + +async fn quic_accept_loop(server: AuraServer, tx: mpsc::Sender) { + loop { + match server.accept().await { + Ok(conn) => { + let peer_id = conn.peer_id().map(str::to_owned); + let accepted = Accepted { + conn: conn.into_dyn(), + mode: TransportMode::Quic, + peer_id, + }; + if tx.send(accepted).await.is_err() { + break; + } + } + Err(e) => { + tracing::warn!("quic accept failed: {e:#}"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + } +} diff --git a/crates/aura-transport/src/lib.rs b/crates/aura-transport/src/lib.rs index e54b057..8e3b4c5 100644 --- a/crates/aura-transport/src/lib.rs +++ b/crates/aura-transport/src/lib.rs @@ -64,15 +64,19 @@ #![warn(missing_docs)] pub mod conn; +pub mod dial; pub mod mimicry; pub mod padding; pub mod quic; +pub mod tcp; pub mod udp; pub use conn::AuraConnection; +pub use dial::{dial, Accepted, DialConfig, Endpoints, MultiServer, TransportMode}; 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}; +pub use tcp::{TcpClient, TcpConnection, TcpOpts, TcpServer}; pub use udp::{UdpClient, UdpConnection, UdpOpts, UdpServer}; // Re-export the inner proto trait so downstream crates (the CLI) can name the connection as diff --git a/crates/aura-transport/src/tcp.rs b/crates/aura-transport/src/tcp.rs new file mode 100644 index 0000000..d7e21ff --- /dev/null +++ b/crates/aura-transport/src/tcp.rs @@ -0,0 +1,256 @@ +//! Aura over plain **TCP** — a fallback transport for networks that block UDP/QUIC (project §7). +//! +//! This runs the SAME Aura proto handshake (hybrid X25519 + ML-KEM-768 + mutual X.509) and +//! [`aura_proto::Session`] directly over a [`TcpStream`], which already implements +//! [`AsyncRead`](tokio::io::AsyncRead) + [`AsyncWrite`](tokio::io::AsyncWrite). No extra crypto and +//! no QUIC are involved — the security boundary is the inner Aura handshake, exactly as for the UDP +//! backend. +//! +//! ## Optional HTTP masquerade +//! +//! With [`TcpOpts::masquerade`] the peers exchange a minimal HTTP/1.1 request/response preamble +//! before the Aura handshake, so the start of the connection resembles a plain HTTP session to a +//! casual observer. This is a **light disguise, not TLS** — full HTTPS/TLS-443 mimicry (reusing the +//! rustls outer layer from the QUIC backend) is a planned enhancement; for now TCP's main job is to +//! get bytes through where UDP is blocked. + +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; + +use aura_proto::{ + client_handshake, server_handshake, ClientConfig, Frame, PacketConnection, ServerConfig, + Session, SessionReceiver, SessionSender, +}; + +/// Tunables for the TCP transport. +#[derive(Clone, Debug)] +pub struct TcpOpts { + /// When `true`, exchange a minimal HTTP/1.1 preamble before the Aura handshake so the connection + /// opening resembles plain HTTP. A light disguise only (not TLS). + pub masquerade: bool, + /// `Host:` header value used in the client's masquerade preamble. + pub host: String, +} + +impl Default for TcpOpts { + fn default() -> Self { + Self { + masquerade: false, + host: "cdn.example.com".to_string(), + } + } +} + +/// The concrete session type carried over TCP: a proto session over TcpStream's owned halves. +type TcpSession = Session; + +/// An established Aura connection carried over **plain TCP**, exposed as a packet pipe. +/// +/// Implements [`aura_proto::PacketConnection`] (so it works behind `Arc`): +/// outbound packets are sealed as [`Frame::Data`] on `stream_id 0`; inbound `Data` payloads are +/// returned; `Ping` is answered with `Pong`, stray `Pong` ignored, `Close` surfaced as an error. +/// Send and receive use **separate** [`tokio::sync::Mutex`]es so the two directions run concurrently. +pub struct TcpConnection { + sender: Mutex>, + receiver: Mutex>, + peer_id: Option, +} + +impl TcpConnection { + fn from_session(session: TcpSession) -> 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 learned during the handshake. + #[must_use] + pub fn peer_id(&self) -> Option<&str> { + self.peer_id.as_deref() + } + + /// Wrap this connection as a trait object for the tunnel/dialer layer. + #[must_use] + pub fn into_dyn(self) -> Arc { + Arc::new(self) + } +} + +#[async_trait] +impl PacketConnection for TcpConnection { + async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> { + self.sender + .lock() + .await + .send_frame(Frame::Data { + stream_id: 0, + payload: Bytes::copy_from_slice(packet), + }) + .await?; + Ok(()) + } + + async fn recv_packet(&self) -> anyhow::Result> { + let mut receiver = self.receiver.lock().await; + loop { + match receiver.recv_frame().await? { + Frame::Data { payload, .. } => return Ok(payload.to_vec()), + Frame::Ping { seq } => { + // Separate mutex from the receive lock we hold => no deadlock. + self.sender.lock().await.send_frame(Frame::Pong { seq }).await?; + } + Frame::Pong { .. } => continue, + Frame::Close { code, reason } => { + anyhow::bail!("peer closed connection (code {code}): {reason}"); + } + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// HTTP masquerade preamble helpers +// --------------------------------------------------------------------------------------------- + +/// Write a plausible HTTP/1.1 request line + headers (client side of the masquerade). +async fn write_client_preamble(stream: &mut TcpStream, host: &str) -> io::Result<()> { + let req = format!( + "GET / HTTP/1.1\r\nHost: {host}\r\nUser-Agent: Mozilla/5.0\r\nAccept: */*\r\nConnection: keep-alive\r\n\r\n" + ); + stream.write_all(req.as_bytes()).await?; + stream.flush().await +} + +/// Write a plausible HTTP/1.1 response head (server side of the masquerade). +async fn write_server_preamble(stream: &mut TcpStream) -> io::Result<()> { + let resp = + "HTTP/1.1 200 OK\r\nServer: nginx\r\nContent-Type: application/octet-stream\r\nConnection: keep-alive\r\n\r\n"; + stream.write_all(resp.as_bytes()).await?; + stream.flush().await +} + +/// Read (and discard) bytes up to and including the `\r\n\r\n` header terminator. +/// +/// Reads one byte at a time so it never consumes past the terminator into the handshake stream. The +/// preamble is tiny and one-time, so byte-at-a-time is fine and keeps the boundary exact. +async fn read_until_headers_end(stream: &mut TcpStream) -> io::Result<()> { + let mut last4 = [0u8; 4]; + let mut count = 0usize; + let mut one = [0u8; 1]; + loop { + let n = stream.read(&mut one).await?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "eof during masquerade preamble", + )); + } + last4.rotate_left(1); + last4[3] = one[0]; + count += 1; + if count >= 4 && &last4 == b"\r\n\r\n" { + return Ok(()); + } + if count > 8192 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "masquerade preamble exceeded 8 KiB without terminator", + )); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Server / client +// --------------------------------------------------------------------------------------------- + +/// An Aura TCP server: a bound [`TcpListener`] that accepts authenticated [`TcpConnection`]s. +pub struct TcpServer { + listener: TcpListener, + proto_cfg: Arc, + opts: TcpOpts, +} + +impl TcpServer { + /// Bind a TCP server on `addr` (use `..:0` for an OS-assigned port, read back with + /// [`TcpServer::local_addr`]). + /// + /// # Errors + /// Returns an [`io::Error`] if the listener cannot bind. + pub async fn bind( + addr: SocketAddr, + proto_cfg: ServerConfig, + opts: TcpOpts, + ) -> io::Result { + let listener = TcpListener::bind(addr).await?; + Ok(Self { + listener, + proto_cfg: Arc::new(proto_cfg), + opts, + }) + } + + /// The local address (incl. the OS-assigned port) this server is bound to. + /// + /// # Errors + /// Returns an [`io::Error`] if the address cannot be read. + pub fn local_addr(&self) -> io::Result { + self.listener.local_addr() + } + + /// Accept the next client: optional masquerade preamble, then the Aura mutual-auth handshake. + /// + /// # Errors + /// Returns an error if accepting fails, the masquerade preamble is malformed, or the Aura + /// handshake fails (e.g. the client's certificate does not verify against the CA). + pub async fn accept(&self) -> anyhow::Result { + let (mut stream, _peer) = self.listener.accept().await?; + stream.set_nodelay(true).ok(); + if self.opts.masquerade { + read_until_headers_end(&mut stream).await?; + write_server_preamble(&mut stream).await?; + } + let (reader, writer) = stream.into_split(); + let session = server_handshake(reader, writer, &self.proto_cfg).await?; + Ok(TcpConnection::from_session(session)) + } +} + +/// An Aura TCP client entry point. +pub struct TcpClient; + +impl TcpClient { + /// Connect to an Aura TCP server at `server`: optional masquerade preamble, then the Aura + /// mutual-auth handshake over the TCP stream. + /// + /// # Errors + /// Returns an error if the TCP connect fails, the masquerade preamble is malformed, or the Aura + /// handshake fails (bad server cert chain, SAN mismatch, ...). + pub async fn connect( + server: SocketAddr, + proto_cfg: ClientConfig, + opts: TcpOpts, + ) -> anyhow::Result { + let mut stream = TcpStream::connect(server).await?; + stream.set_nodelay(true).ok(); + if opts.masquerade { + write_client_preamble(&mut stream, &opts.host).await?; + read_until_headers_end(&mut stream).await?; + } + let (reader, writer) = stream.into_split(); + let session = client_handshake(reader, writer, &proto_cfg).await?; + Ok(TcpConnection::from_session(session)) + } +} diff --git a/crates/aura-transport/tests/dial_fallback.rs b/crates/aura-transport/tests/dial_fallback.rs new file mode 100644 index 0000000..5048745 --- /dev/null +++ b/crates/aura-transport/tests/dial_fallback.rs @@ -0,0 +1,74 @@ +//! Verifies the client dialer's handover: when an earlier transport in the order is unreachable, +//! `dial` moves on to the next and connects. + +use std::net::SocketAddr; +use std::time::Duration; + +use aura_pki::AuraCa; +use aura_proto::{ClientConfig, PacketConnection, ServerConfig}; +use aura_transport::{dial, DialConfig, Endpoints, TcpOpts, TransportMode, UdpOpts, UdpServer}; + +fn make_configs() -> (ServerConfig, ClientConfig) { + let ca = AuraCa::generate("Aura Test CA").expect("generate CA"); + let server = ca.issue_server_cert("localhost").expect("issue server cert"); + let client = ca.issue_client_cert("client-dial").expect("issue client cert"); + let ca_pem = ca.ca_cert_pem(); + ( + ServerConfig { + ca_cert_pem: ca_pem.clone(), + server_cert_pem: server.cert_pem, + server_key_pem: server.key_pem, + }, + ClientConfig { + ca_cert_pem: ca_pem, + client_cert_pem: client.cert_pem, + client_key_pem: client.key_pem, + server_name: "localhost".to_string(), + }, + ) +} + +#[tokio::test] +async fn dial_falls_back_from_dead_tcp_to_udp() { + let (scfg, ccfg) = make_configs(); + + // A real UDP server (the working fallback target). + let udp_server = + UdpServer::bind("127.0.0.1:0".parse().unwrap(), scfg, UdpOpts::default()).expect("bind udp"); + let udp_addr = udp_server.local_addr().expect("udp addr"); + let srv = tokio::spawn(async move { + let conn = udp_server.accept().await.expect("server accept"); + let p = conn.recv_packet().await.expect("server recv"); + conn.send_packet(&p).await.expect("server echo"); + }); + + // Port 1 on loopback has nothing listening → TCP connect is refused fast. + let dead_tcp: SocketAddr = "127.0.0.1:1".parse().unwrap(); + + let cfg = DialConfig { + endpoints: Endpoints { + udp: Some(udp_addr), + tcp: Some(dead_tcp), + quic: None, + }, + sni: "cdn.example.com".to_string(), + // Deliberately try the (dead) TCP first to force the handover to UDP. + order: vec![TransportMode::Tcp, TransportMode::Udp], + udp: UdpOpts::default(), + tcp: TcpOpts::default(), + attempt_timeout: Duration::from_secs(3), + }; + + let (conn, mode) = dial(ccfg, cfg).await.expect("dial should fall back to UDP"); + assert_eq!( + mode, + TransportMode::Udp, + "must hand over to UDP after TCP is refused" + ); + + conn.send_packet(b"hello-fallback").await.expect("send"); + let echoed = conn.recv_packet().await.expect("recv"); + assert_eq!(echoed, b"hello-fallback"); + + srv.await.expect("server task"); +} diff --git a/crates/aura-transport/tests/tcp_loopback.rs b/crates/aura-transport/tests/tcp_loopback.rs new file mode 100644 index 0000000..98edb4b --- /dev/null +++ b/crates/aura-transport/tests/tcp_loopback.rs @@ -0,0 +1,72 @@ +//! End-to-end loopback test for the TCP fallback transport: real TCP on 127.0.0.1, full Aura +//! mutual-auth handshake, packet echo — with the HTTP masquerade both off and on. + +use aura_pki::AuraCa; +use aura_proto::{ClientConfig, PacketConnection, ServerConfig}; +use aura_transport::{TcpClient, TcpOpts, TcpServer}; + +/// Mint a fresh CA + server("localhost") + client("client-tcp") and build the proto configs. +fn make_configs() -> (ServerConfig, ClientConfig) { + let ca = AuraCa::generate("Aura Test CA").expect("generate CA"); + let server = ca.issue_server_cert("localhost").expect("issue server cert"); + let client = ca.issue_client_cert("client-tcp").expect("issue client cert"); + let ca_pem = ca.ca_cert_pem(); + let scfg = ServerConfig { + ca_cert_pem: ca_pem.clone(), + server_cert_pem: server.cert_pem, + server_key_pem: server.key_pem, + }; + let ccfg = ClientConfig { + ca_cert_pem: ca_pem, + client_cert_pem: client.cert_pem, + client_key_pem: client.key_pem, + server_name: "localhost".to_string(), + }; + (scfg, ccfg) +} + +async fn run_case(opts: TcpOpts) { + let (scfg, ccfg) = make_configs(); + let server = TcpServer::bind("127.0.0.1:0".parse().unwrap(), scfg, opts.clone()) + .await + .expect("bind server"); + let addr = server.local_addr().expect("local addr"); + + let server_task = tokio::spawn(async move { + let conn = server.accept().await.expect("server handshake"); + assert_eq!(conn.peer_id(), Some("client-tcp"), "verified client id"); + // Echo three packets back to the client. + for _ in 0..3 { + let pkt = conn.recv_packet().await.expect("server recv"); + conn.send_packet(&pkt).await.expect("server echo"); + } + }); + + let client = TcpClient::connect(addr, ccfg, opts) + .await + .expect("client handshake"); + + // Exchange packets of varying sizes (incl. a large one) and assert the echo matches. + for i in 0..3u16 { + let payload = vec![(i as u8).wrapping_add(1); 100 + (i as usize) * 600]; // 100, 700, 1300 bytes + client.send_packet(&payload).await.expect("client send"); + let echoed = client.recv_packet().await.expect("client recv"); + assert_eq!(echoed, payload, "round-trip payload mismatch"); + } + + server_task.await.expect("server task"); +} + +#[tokio::test] +async fn tcp_loopback_end_to_end_plain() { + run_case(TcpOpts::default()).await; +} + +#[tokio::test] +async fn tcp_loopback_end_to_end_masquerade() { + run_case(TcpOpts { + masquerade: true, + host: "cdn.example.com".to_string(), + }) + .await; +}