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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<dyn aura_proto::PacketConnection>`, 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<Self, Self::Err> {
|
||||
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<SocketAddr>,
|
||||
/// Address of the TCP transport.
|
||||
pub tcp: Option<SocketAddr>,
|
||||
/// Address of the QUIC transport.
|
||||
pub quic: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
/// 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<TransportMode>,
|
||||
/// 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<dyn PacketConnection>, TransportMode)> {
|
||||
let mut last_err: Option<anyhow::Error> = 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<Arc<dyn PacketConnection>> {
|
||||
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<dyn PacketConnection>,
|
||||
/// Which transport accepted it.
|
||||
pub mode: TransportMode,
|
||||
/// Verified peer Common Name (client id), if any.
|
||||
pub peer_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Accepted>,
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
let (txc, rx) = mpsc::channel::<Accepted>(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<Accepted> {
|
||||
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<Accepted>) {
|
||||
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<Accepted>) {
|
||||
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<Accepted>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<OwnedReadHalf, OwnedWriteHalf>;
|
||||
|
||||
/// An established Aura connection carried over **plain TCP**, exposed as a packet pipe.
|
||||
///
|
||||
/// Implements [`aura_proto::PacketConnection`] (so it works behind `Arc<dyn PacketConnection>`):
|
||||
/// 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<SessionSender<OwnedWriteHalf>>,
|
||||
receiver: Mutex<SessionReceiver<OwnedReadHalf>>,
|
||||
peer_id: Option<String>,
|
||||
}
|
||||
|
||||
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<dyn PacketConnection> {
|
||||
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<Vec<u8>> {
|
||||
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<ServerConfig>,
|
||||
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<Self> {
|
||||
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<SocketAddr> {
|
||||
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<TcpConnection> {
|
||||
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<TcpConnection> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user