diff --git a/Cargo.lock b/Cargo.lock index 9b8caa5..6ddbafe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5340,7 +5340,6 @@ dependencies = [ "wind-dns", "wind-socks", "wind-tuic", - "wind-tuiche", ] [[package]] @@ -5367,7 +5366,6 @@ dependencies = [ "uuid", "wind-core", "wind-tuic", - "wind-tuiche", ] [[package]] @@ -5810,6 +5808,7 @@ dependencies = [ name = "wind-quic" version = "0.1.1" dependencies = [ + "arc-swap", "aws-lc-rs", "boring", "boring-sys", @@ -5910,27 +5909,7 @@ dependencies = [ "tuic-core", "uuid", "wind-core", -] - -[[package]] -name = "wind-tuiche" -version = "0.1.1" -dependencies = [ - "arc-swap", - "boring", - "boring-sys", - "bytes", - "eyre", - "foreign-types-shared", - "futures-util", - "thiserror 2.0.18", - "tokio", - "tokio-quiche", - "tokio-util", - "tracing", - "tuic-core", - "uuid", - "wind-core", + "wind-quic", ] [[package]] diff --git a/crates/tuic-core/Cargo.toml b/crates/tuic-core/Cargo.toml index 1e33ead..63f9760 100644 --- a/crates/tuic-core/Cargo.toml +++ b/crates/tuic-core/Cargo.toml @@ -3,7 +3,7 @@ name = "tuic-core" version.workspace = true repository.workspace = true edition.workspace = true -description = "Backend-agnostic TUIC protocol codecs and state machine shared by wind-tuic and wind-tuiche" +description = "Backend-agnostic TUIC protocol codecs and state machine shared by wind-tuic's quinn and quiche backends" license = "MIT OR Apache-2.0" [features] diff --git a/crates/tuic-core/src/lib.rs b/crates/tuic-core/src/lib.rs index 54ebd0d..4ba6481 100644 --- a/crates/tuic-core/src/lib.rs +++ b/crates/tuic-core/src/lib.rs @@ -1,5 +1,5 @@ -//! Backend-agnostic TUIC protocol primitives shared by the quinn-based -//! [`wind-tuic`] crate and the tokio-quiche-based [`wind-tuiche`] crate. +//! Backend-agnostic TUIC protocol primitives shared by the `wind-tuic` crate's +//! quinn and quiche (tokio-quiche) backends. //! //! This crate deliberately has **no QUIC backend dependency**. It contains: //! @@ -10,7 +10,8 @@ //! ([`FragmentReassemblyBuffer`](udp::FragmentReassemblyBuffer)). //! //! The backend-specific glue (opening QUIC streams, sending datagrams, the -//! connection lifecycle) lives in the `wind-tuic` / `wind-tuiche` crates. +//! connection lifecycle) lives in the `wind-tuic` crate, generic over the +//! `wind-quic` QUIC abstraction. pub mod proto; pub mod udp; diff --git a/crates/tuic-server/Cargo.toml b/crates/tuic-server/Cargo.toml index 104d14f..b5e16b9 100644 --- a/crates/tuic-server/Cargo.toml +++ b/crates/tuic-server/Cargo.toml @@ -17,10 +17,11 @@ ring = ["rustls/ring", "rcgen/ring", "quinn/rustls-ring"] aws-lc-rs = ["dep:aws-lc-rs", "rustls/aws-lc-rs", "rcgen/aws_lc_rs", "quinn/rustls-aws-lc-rs"] jemallocator = ["tikv-jemallocator"] quinn = ["wind-tuic/quinn"] -# The tokio-quiche (`wind-tuiche`) backend. 64-bit only — tokio-quiche does not -# compile on 32-bit (its GSO path transmutes `u128` -> `Instant`). Enabled per -# target via `.github/target.toml`; do NOT enable on 32-bit targets. -quiche = ["dep:wind-tuiche"] +# The tokio-quiche backend, now hosted inside wind-tuic behind its `quiche` +# feature. 64-bit only — tokio-quiche does not compile on 32-bit (its GSO path +# transmutes `u128` -> `Instant`). Enabled per target via `.github/target.toml`; +# do NOT enable on 32-bit targets. +quiche = ["wind-tuic/quiche"] [dependencies] @@ -30,7 +31,6 @@ wind-base = { path = "../wind-base" } wind-dns = { path = "../wind-dns" } wind-socks = { path = "../wind-socks" } wind-tuic = { path = "../wind-tuic", features = ["server"] } -wind-tuiche = { path = "../wind-tuiche", default-features = false, features = ["server"], optional = true } wind-acme = { path = "../wind-acme" } toml = "1.0" diff --git a/crates/tuic-server/src/log.rs b/crates/tuic-server/src/log.rs index 445108c..54e4687 100644 --- a/crates/tuic-server/src/log.rs +++ b/crates/tuic-server/src/log.rs @@ -28,7 +28,6 @@ pub fn init(config: &Config) -> Result { ("wind_socks", LevelFilter::from(config.log_level)), ("wind_acme", LevelFilter::from(config.log_level)), ("wind_base", LevelFilter::from(config.log_level)), - ("wind_tuiche", LevelFilter::from(config.log_level)), ("wind_dns", LevelFilter::from(config.log_level)), ]) .with_default(max(LogLevel::Info, config.log_level)); diff --git a/crates/tuic-server/src/wind_adapter.rs b/crates/tuic-server/src/wind_adapter.rs index cdfacdb..8723bdc 100644 --- a/crates/tuic-server/src/wind_adapter.rs +++ b/crates/tuic-server/src/wind_adapter.rs @@ -41,11 +41,11 @@ use wind_core::{ utils::{StackPrefer, is_private_ip}, }; use wind_socks::action::{Socks5Action, Socks5ActionOpts}; -use wind_tuic::quinn::inbound::{TuicInbound, TuicInboundOpts}; // The quiche backend lives behind the `quiche` cargo feature (enabled per target // via `.github/target.toml`). #[cfg(feature = "quiche")] -use wind_tuiche::{TuicheInbound, TuicheInboundBuilder}; +use wind_tuic::quiche::{TuicheInbound, TuicheInboundBuilder}; +use wind_tuic::quinn::inbound::{TuicInbound, TuicInboundOpts}; // `CongestionController` is only referenced by the quiche backend wiring. #[cfg(feature = "quiche")] @@ -302,19 +302,19 @@ async fn create_quiche_inbound(ctx: &Arc) -> eyre::Result wind_tuiche::CongestionControl::Cubic, - CongestionController::Bbr | CongestionController::Bbr3 => wind_tuiche::CongestionControl::Bbr, - CongestionController::NewReno => wind_tuiche::CongestionControl::Reno, + CongestionController::Cubic => wind_tuic::quiche::CongestionControl::Cubic, + CongestionController::Bbr | CongestionController::Bbr3 => wind_tuic::quiche::CongestionControl::Bbr, + CongestionController::NewReno => wind_tuic::quiche::CongestionControl::Reno, }; - let opts = wind_tuiche::ConnectionOpts { + let opts = wind_tuic::quiche::ConnectionOpts { max_idle_timeout: quiche.max_idle_time, max_concurrent_bi_streams: quiche.max_concurrent_bi_streams, max_concurrent_uni_streams: quiche.max_concurrent_uni_streams, send_window: quiche.send_window, receive_window: quiche.receive_window, congestion_control, - udp_relay_mode: wind_tuiche::UdpRelayMode::Datagram, + udp_relay_mode: wind_tuic::quiche::UdpRelayMode::Datagram, enable_0rtt: quiche.zero_rtt, }; @@ -340,13 +340,13 @@ async fn create_quiche_inbound(ctx: &Arc) -> eyre::Result, - store: wind_tuiche::CertStore, + store: wind_tuic::quiche::CertStore, mut rx: tokio::sync::watch::Receiver>, cert_path: String, key_path: String, diff --git a/crates/tuic-tests/Cargo.toml b/crates/tuic-tests/Cargo.toml index 633cc08..fed9475 100644 --- a/crates/tuic-tests/Cargo.toml +++ b/crates/tuic-tests/Cargo.toml @@ -36,11 +36,12 @@ tokio-util = { version = "0.7", features = ["codec"] } # The quiche e2e tests only *run* on 64-bit (cross-emulated 32-bit test execution # is unreliable for real sockets). On 64-bit we enable tuic-server's `quiche` -# feature (so the tests exercise it) and pull wind-tuiche + a raw quinn client + -# rcgen for the cert-reload test. On 32-bit none of this is built and the quiche +# feature (so the tests exercise it), add wind-tuic's `quiche` feature (for the +# cert-reload test's direct `wind_tuic::quiche::TuicheInboundBuilder` use), and +# pull a raw quinn client + rcgen. On 32-bit none of this is built and the quiche # test files compile to nothing. (The quiche backend itself builds on 32-bit too.) [target.'cfg(target_pointer_width = "64")'.dependencies] tuic-server = { path = "../tuic-server", default-features = false, features = ["quiche"] } -wind-tuiche = { path = "../wind-tuiche", default-features = false, features = ["server"] } +wind-tuic = { path = "../wind-tuic", default-features = false, features = ["quiche"] } quinn = { workspace = true, default-features = false, features = ["runtime-tokio", "rustls-aws-lc-rs"] } rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } \ No newline at end of file diff --git a/crates/tuic-tests/tests/quiche_cert_reload.rs b/crates/tuic-tests/tests/quiche_cert_reload.rs index c640417..68eebc9 100644 --- a/crates/tuic-tests/tests/quiche_cert_reload.rs +++ b/crates/tuic-tests/tests/quiche_cert_reload.rs @@ -22,7 +22,7 @@ use rustls::{ pki_types::{CertificateDer, ServerName, UnixTime}, }; use wind_core::{AbstractInbound, InboundCallback, tcp::AbstractTcpStream, types::TargetAddr, udp::UdpStream}; -use wind_tuiche::TuicheInboundBuilder; +use wind_tuic::quiche::TuicheInboundBuilder; // ---- a no-op inbound callback (TLS handshake is all we need) -------------- diff --git a/crates/wind-core/src/tcp.rs b/crates/wind-core/src/tcp.rs index c6a4c97..28cdc91 100644 --- a/crates/wind-core/src/tcp.rs +++ b/crates/wind-core/src/tcp.rs @@ -1,5 +1,12 @@ use tokio::io::{AsyncRead, AsyncWrite}; -pub trait AbstractTcpStream: AsyncRead + AsyncWrite + Send + Sync + Unpin {} +/// A duplex byte stream relayed by the proxy. +/// +/// `Sync` is intentionally **not** required: relay streams are always owned and +/// moved into a spawned task (which needs `Send`, not `Sync`). Requiring `Sync` +/// would exclude perfectly valid streams whose halves are joined from channel +/// senders/receivers — e.g. the quiche QUIC backend's `tokio_util::PollSender`, +/// which is `Send` but not `Sync`. +pub trait AbstractTcpStream: AsyncRead + AsyncWrite + Send + Unpin {} -impl AbstractTcpStream for T where T: AsyncRead + AsyncWrite + Send + Sync + Unpin {} +impl AbstractTcpStream for T where T: AsyncRead + AsyncWrite + Send + Unpin {} diff --git a/crates/wind-quic/Cargo.toml b/crates/wind-quic/Cargo.toml index 1a4ff0a..7bc0d89 100644 --- a/crates/wind-quic/Cargo.toml +++ b/crates/wind-quic/Cargo.toml @@ -32,6 +32,7 @@ quiche = [ "dep:boring", "dep:boring-sys", "dep:foreign-types-shared", + "dep:arc-swap", ] [dependencies] @@ -62,6 +63,7 @@ tokio-quiche = { version = "0.19", optional = true } boring = { version = "4", default-features = false, optional = true } boring-sys = { version = "4", default-features = false, optional = true } foreign-types-shared = { version = "0.3", optional = true } +arc-swap = { version = "1", optional = true } [dev-dependencies] tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] } diff --git a/crates/wind-quic/src/quiche/mod.rs b/crates/wind-quic/src/quiche/mod.rs index 2c59299..a6afdda 100644 --- a/crates/wind-quic/src/quiche/mod.rs +++ b/crates/wind-quic/src/quiche/mod.rs @@ -8,12 +8,17 @@ pub mod conn; pub mod driver; pub mod stream; +pub mod tls; -use std::net::{Ipv4Addr, SocketAddr}; +use std::{ + net::{Ipv4Addr, SocketAddr}, + sync::Arc, +}; pub use conn::QuicheConnection; use futures_util::StreamExt as _; pub use stream::{QuicheRecv, QuicheSend}; +pub use tls::CertStore; use tokio::{net::UdpSocket, sync::mpsc}; use tokio_quiche::{ ConnectionParams, @@ -27,7 +32,7 @@ use tracing::warn; use crate::{ config::{CertSource, ClientTlsConfig, ServerTlsConfig, TransportConfig}, error::QuicError, - quiche::driver::BridgeDriver, + quiche::{driver::BridgeDriver, tls::CertReloadHook}, }; /// Map the shared congestion-control selector onto a quiche cc-algorithm name. @@ -86,10 +91,19 @@ impl QuicheAcceptor { /// /// The quiche backend loads TLS credentials from file paths, so `tls_cfg` must /// use [`CertSource::PemPaths`]. +/// +/// When `cert_store` is `Some`, a per-handshake [`ConnectionHook`] serves the +/// store's *current* certificate, enabling live rotation (e.g. ACME renewal) +/// without restarting the listener. The certificate files in `tls_cfg` are +/// still required by tokio-quiche's API, but the hook supersedes them for the +/// actual TLS context. +/// +/// [`ConnectionHook`]: tokio_quiche::quic::ConnectionHook pub async fn bind_server( addr: SocketAddr, tls_cfg: &ServerTlsConfig, transport: &TransportConfig, + cert_store: Option<&CertStore>, ) -> Result { let (cert, key) = match &tls_cfg.cert { CertSource::PemPaths { cert, key } => (cert.clone(), key.clone()), @@ -107,6 +121,10 @@ pub async fn bind_server( .local_addr() .map_err(|e| QuicError::Endpoint(format!("local_addr: {e}")))?; + let hooks = Hooks { + connection_hook: cert_store.map(|s| Arc::new(CertReloadHook::new(s.clone())) as _), + }; + let params = ConnectionParams::new_server( quic_settings(transport), TlsCertificatePaths { @@ -114,7 +132,7 @@ pub async fn bind_server( private_key: &key, kind: CertificateKind::X509, }, - Hooks { connection_hook: None }, + hooks, ); let mut listeners = tokio_quiche::listen([socket], params, DefaultMetrics) diff --git a/crates/wind-tuiche/src/tls.rs b/crates/wind-quic/src/quiche/tls.rs similarity index 85% rename from crates/wind-tuiche/src/tls.rs rename to crates/wind-quic/src/quiche/tls.rs index 5ff1758..7b9cd26 100644 --- a/crates/wind-tuiche/src/tls.rs +++ b/crates/wind-quic/src/quiche/tls.rs @@ -1,9 +1,8 @@ -//! Hot-reloadable TLS for the tokio-quiche backend. +//! Hot-reloadable TLS for the quiche backend. //! //! tokio-quiche loads the certificate into the BoringSSL `SSL_CTX` **once** at -//! `listen()` time (see `tokio_quiche::settings::config`) and reuses that -//! context for every connection, so a renewed certificate on disk is never -//! picked up without a restart. +//! `listen()` time and reuses that context for every connection, so a renewed +//! certificate on disk is never picked up without a restart. //! //! We work around this through the only TLS customization seam tokio-quiche //! exposes — [`ConnectionHook::create_custom_ssl_context_builder`]. The hook @@ -13,9 +12,11 @@ //! handshakes with no listener restart; in-flight connections keep the //! certificate they negotiated. //! -//! Note: the cert is installed on the per-connection `SslRef` (not by swapping -//! the whole `SSL_CTX`), so quiche's own QUIC TLS method on the context stays -//! intact. +//! This is pure QUIC-TLS infrastructure (boring-based) — independent of any +//! application protocol — so it lives in `wind-quic` and is shared by every +//! quiche-backed server. `wind-quic` exposes it so quiche servers can opt into +//! live certificate rotation (e.g. ACME renewal); quinn/rustls has no +//! equivalent seam. use std::sync::Arc; @@ -82,13 +83,13 @@ impl CertStore { /// A [`ConnectionHook`] that serves the current certificate from a /// [`CertStore`] via a per-handshake `select_certificate` callback, enabling -/// live cert rotation on the tokio-quiche backend. -pub struct CertReloadHook { +/// live cert rotation on the quiche backend. +pub(crate) struct CertReloadHook { store: CertStore, } impl CertReloadHook { - pub fn new(store: CertStore) -> Self { + pub(crate) fn new(store: CertStore) -> Self { Self { store } } } diff --git a/crates/wind-quic/tests/loopback.rs b/crates/wind-quic/tests/loopback.rs index 7f2529b..1a4ff05 100644 --- a/crates/wind-quic/tests/loopback.rs +++ b/crates/wind-quic/tests/loopback.rs @@ -151,7 +151,9 @@ async fn quiche_loopback() { let (server_tls, client_tls, transport) = configs(&cert, &key); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let mut acceptor = quiche::bind_server(addr, &server_tls, &transport).await.expect("bind_server"); + let mut acceptor = quiche::bind_server(addr, &server_tls, &transport, None) + .await + .expect("bind_server"); let local = acceptor.local_addr(); let server_fut = async move { acceptor.accept().await.expect("server conn") }; diff --git a/crates/wind-tuic/Cargo.toml b/crates/wind-tuic/Cargo.toml index 1065b39..7d09e3a 100644 --- a/crates/wind-tuic/Cargo.toml +++ b/crates/wind-tuic/Cargo.toml @@ -10,11 +10,20 @@ license = "MIT OR Apache-2.0" default = ["server", "client", "quinn", "aws-lc-rs"] decode = ["tuic-core/decode"] encode = ["tuic-core/encode"] -server = ["decode"] +# The server decodes client requests AND encodes UDP response datagrams, so it +# needs both codec directions (matching the former wind-tuiche `server` feature). +server = ["decode", "encode"] client = ["encode"] # Quinn Backend -quinn = ["dep:quinn", "dep:quinn-congestions", "dep:tokio-rustls", "dep:rustls", "dep:rustls-platform-verifier"] +quinn = [ + "wind-quic/quinn", + "dep:quinn", + "dep:quinn-congestions", + "dep:tokio-rustls", + "dep:rustls", + "dep:rustls-platform-verifier", +] aws-lc-rs = [ "rustls/aws-lc-rs", "quinn/rustls-aws-lc-rs", @@ -26,12 +35,20 @@ ring = [ "quinn/rustls-ring" ] +# Quiche (tokio-quiche) backend — the TUIC protocol runs through the same +# backend-agnostic core over `wind_quic::quiche::QuicheConnection`. +quiche = ["wind-quic/quiche"] + [dependencies] wind-core = { version = "0.1.1", path = "../wind-core", features = ["quic"] } tuic-core = { version = "0.1.1", path = "../tuic-core", default-features = false } +# Backend-agnostic QUIC abstraction. The TUIC protocol core is generic over +# `wind_quic::QuicConnection`; the `quinn`/`quiche` features enable the matching +# wind-quic backend. +wind-quic = { version = "0.1.1", path = "../wind-quic", default-features = false } # Async -tokio = { version = "1", default-features = false, features = ["net", "time", "rt", "sync"] } +tokio = { version = "1", default-features = false, features = ["net", "time", "rt", "sync", "io-util", "macros"] } tokio-util = { version = "0.7", features = ["codec"] } tokio-stream = "0.1" futures-util = { version = "0.3", default-features = false, features = ["sink"] } diff --git a/crates/wind-tuic/src/quinn/task.rs b/crates/wind-tuic/src/client/mod.rs similarity index 56% rename from crates/wind-tuic/src/quinn/task.rs rename to crates/wind-tuic/src/client/mod.rs index 552a71d..c16afe1 100644 --- a/crates/wind-tuic/src/quinn/task.rs +++ b/crates/wind-tuic/src/client/mod.rs @@ -1,38 +1,48 @@ +//! Backend-agnostic TUIC client plumbing. +//! +//! [`ClientTaskExt::handle_incoming`] spawns the accept loops (datagram / bi / +//! uni) for an established [`QuicConnection`] and returns receive channels the +//! outbound poll loop drains. Written once against the trait; the concrete +//! `TuicOutbound` (currently quinn-only) drives it. + use std::{sync::Arc, time::Duration}; use bytes::Bytes; use crossfire::{AsyncRx, SendTimeoutError, spsc}; -use quinn::{RecvStream, SendStream}; use tokio_util::sync::CancellationToken; use tracing::{Instrument as _, info, warn}; use wind_core::AppContext; +use wind_quic::QuicConnection; use crate::Error; -/// Size of the single-producer single-consumer buffer for QUIC streams. -/// Larger buffers reduce backpressure stalls on bursty workloads at the cost -/// of a small amount of extra memory per connection. +/// Size of the single-producer single-consumer buffer for QUIC streams. Larger +/// buffers reduce backpressure stalls on bursty workloads at the cost of a +/// small amount of extra memory per connection. const SPSC_BUFFER_SIZE: usize = 64; -type IncomingRx = ( +/// Receivers returned by [`ClientTaskExt::handle_incoming`]: datagrams, +/// incoming bidirectional streams, and incoming unidirectional streams. +type IncomingRx = ( AsyncRx>, - AsyncRx>, - AsyncRx>, + AsyncRx::SendStream, ::RecvStream)>>, + AsyncRx::RecvStream>>, ); -/// Generic helper to spawn a task that handles incoming items from a QUIC -/// connection and forwards them to a channel -async fn spawn_handler( +/// Spawn a task that drives an `accept`-style call and forwards each accepted +/// item to a channel. +async fn spawn_handler( ctx: Arc, - connection: quinn::Connection, + connection: C, cancel_token: CancellationToken, accept_fn: F, name: &'static str, ) -> AsyncRx> where + C: QuicConnection, T: Send + Unpin + 'static, - F: Fn(quinn::Connection) -> Fut + Send + 'static, - Fut: std::future::Future> + Send, + F: Fn(C) -> Fut + Send + 'static, + Fut: std::future::Future> + Send, { let (tx, rx) = spsc::bounded_async(SPSC_BUFFER_SIZE); @@ -50,12 +60,9 @@ where }; info!("Accepted new {}", name); - // Distinguish a closed channel (consumer permanently - // gone — we must exit) from a slow consumer (transient - // back-pressure — drop the item and keep accepting). - // Previously both bailed out of the accept loop, so a - // single slow downstream pinned every future incoming - // stream/datagram on this connection. + // Distinguish a closed channel (consumer permanently gone — + // exit) from a slow consumer (transient back-pressure — drop + // the item and keep accepting). match tx.send_timeout(item, Duration::from_secs(1)).await { Ok(()) => {} Err(SendTimeoutError::Disconnected(_)) => { @@ -81,13 +88,16 @@ where rx } -pub trait ClientTaskExt { - async fn handle_incoming(&self, ctx: Arc, cancel_token: CancellationToken) -> Result; +pub trait ClientTaskExt: QuicConnection { + fn handle_incoming( + &self, + ctx: Arc, + cancel_token: CancellationToken, + ) -> impl std::future::Future, Error>> + Send; } -impl ClientTaskExt for quinn::Connection { - async fn handle_incoming(&self, ctx: Arc, cancel_token: CancellationToken) -> Result { - // Spawn task for handling datagrams +impl ClientTaskExt for C { + async fn handle_incoming(&self, ctx: Arc, cancel_token: CancellationToken) -> Result, Error> { let datagram_rx = spawn_handler( ctx.clone(), self.clone(), @@ -97,7 +107,6 @@ impl ClientTaskExt for quinn::Connection { ) .await; - // Spawn task for handling bidirectional streams let bi_rx = spawn_handler( ctx.clone(), self.clone(), @@ -107,7 +116,6 @@ impl ClientTaskExt for quinn::Connection { ) .await; - // Spawn task for handling unidirectional streams let uni_rx = spawn_handler( ctx.clone(), self.clone(), @@ -117,8 +125,6 @@ impl ClientTaskExt for quinn::Connection { ) .await; - // Return the tuple of receivers for datagrams, bidirectional, and - // unidirectional streams Ok((datagram_rx, bi_rx, uni_rx)) } } diff --git a/crates/wind-tuic/src/lib.rs b/crates/wind-tuic/src/lib.rs index 803c5b1..a74cb6b 100644 --- a/crates/wind-tuic/src/lib.rs +++ b/crates/wind-tuic/src/lib.rs @@ -1,6 +1,24 @@ +//! TUIC (TCP/UDP over QUIC) — a single crate supporting both the quinn and +//! quiche (tokio-quiche) backends. +//! +//! The TUIC protocol logic is written once, generic over +//! [`wind_quic::QuicConnection`] (see [`server`] and [`client`]); the backend +//! modules ([`quinn`], [`quiche`]) are thin connection providers selected by +//! the `quinn` / `quiche` features. + pub mod proto; + +#[cfg(feature = "server")] +pub mod server; + +#[cfg(feature = "client")] +pub mod client; + #[cfg(feature = "quinn")] pub mod quinn; +#[cfg(feature = "quiche")] +pub mod quiche; + pub type Error = eyre::Report; pub type Result = eyre::Result; diff --git a/crates/wind-tuic/src/proto/client_proto.rs b/crates/wind-tuic/src/proto/client_proto.rs new file mode 100644 index 0000000..bf4094a --- /dev/null +++ b/crates/wind-tuic/src/proto/client_proto.rs @@ -0,0 +1,168 @@ +//! Connection-coupled TUIC senders, generic over [`wind_quic::QuicConnection`]. +//! +//! Gated on the `encode` feature (it builds wire frames via the `tuic_core` +//! encoders). Both the client outbound and the server's UDP response path use +//! [`ClientProtoExt`]. + +use std::future::Future; + +use bytes::BytesMut; +use eyre::eyre; +use tokio::io::AsyncWriteExt as _; +use tokio_util::codec::Encoder; +use wind_core::{tcp::AbstractTcpStream, types::TargetAddr}; +use wind_quic::{QuicConnection, QuicSendStream as _}; + +use crate::{ + Error, + proto::{Address, AddressCodec, CmdCodec, CmdType, Command, Header, HeaderCodec}, +}; + +/// Encode a single command (+ optional address) and ship it on a fresh +/// unidirectional stream, finishing cleanly so the peer observes EOF. +pub async fn encode_and_send_uni( + conn: &C, + cmd_type: CmdType, + command: Command, + address: Option
, +) -> Result<(), Error> { + let mut buf = BytesMut::with_capacity(64); + HeaderCodec.encode(Header::new(cmd_type), &mut buf)?; + CmdCodec(cmd_type).encode(command, &mut buf)?; + if let Some(addr) = address { + AddressCodec.encode(addr, &mut buf)?; + } + let mut send = conn.open_uni().await?; + send.write_all(&buf).await?; + // Finish the stream so the peer's `read_to_end` (or equivalent EOF detector) + // observes a clean end-of-stream marker. Without this, dropping `send` resets + // the stream and the receiver sees a RESET_STREAM frame racing the payload, + // which intermittently breaks auth/dissociate/uni-UDP paths. + send.finish()?; + Ok(()) +} + +/// Client-side TUIC senders, available on any [`QuicConnection`]. Despite the +/// name the server's UDP response path uses +/// [`send_udp`](ClientProtoExt::send_udp) +/// too (via [`UdpStream`](super::UdpStream)). +pub trait ClientProtoExt: QuicConnection { + fn send_auth(&self, uuid: &uuid::Uuid, secret: &[u8]) -> impl Future> + Send; + fn send_heartbeat(&self) -> impl Future> + Send; + fn open_tcp( + &self, + addr: &TargetAddr, + stream: impl AbstractTcpStream, + ) -> impl Future> + Send; + fn send_udp( + &self, + assoc_id: u16, + pkt_id: u16, + addr: &TargetAddr, + packet: bytes::Bytes, + datagram: bool, + ) -> impl Future> + Send; + fn drop_udp(&self, assoc_id: u16) -> impl Future> + Send; +} + +impl ClientProtoExt for C { + async fn send_auth(&self, uuid: &uuid::Uuid, secret: &[u8]) -> Result<(), Error> { + // Generate the authentication token from the TLS keying-material exporter + // (RFC 5705): label = UUID bytes, context = password. + let mut token = [0u8; 32]; + self.export_keying_material(&mut token, uuid.as_bytes(), secret) + .await + .map_err(|e| eyre!("export_keying_material failed: {e}"))?; + + let auth_cmd = Command::Auth { uuid: *uuid, token }; + + // 2 bytes header + 16 bytes UUID + 32 bytes token. + let mut buf = BytesMut::with_capacity(2 + 16 + 32); + HeaderCodec.encode(Header::new(CmdType::Auth), &mut buf)?; + CmdCodec(CmdType::Auth).encode(auth_cmd, &mut buf)?; + + let mut send = self.open_uni().await?; + send.write_all(&buf).await?; + // Clean EOF — see note on `encode_and_send_uni`. + send.finish()?; + Ok(()) + } + + async fn open_tcp(&self, addr: &TargetAddr, mut stream: impl AbstractTcpStream) -> Result<(usize, usize), Error> { + let (mut send, recv) = self.open_bi().await?; + let mut buf = BytesMut::with_capacity(9); + HeaderCodec.encode(Header::new(CmdType::Connect), &mut buf)?; + CmdCodec(CmdType::Connect).encode(Command::Connect, &mut buf)?; + AddressCodec.encode(addr.to_owned().into(), &mut buf)?; + send.write_all(&buf).await?; + // Join the recv/send halves into one duplex stream for the bidirectional + // relay (replaces the quinn-specific `QuinnCompat`). + let mut duplex = tokio::io::join(recv, send); + let (a, b, err) = wind_core::io::copy_io(&mut stream, &mut duplex).await; + if let Some(e) = err { + return Err(e.into()); + } + Ok((a, b)) + } + + async fn send_udp( + &self, + assoc_id: u16, + pkt_id: u16, + addr: &TargetAddr, + payload: bytes::Bytes, + datagram: bool, + ) -> Result<(), Error> { + // Pre-size for header (2) + Packet command (8) + address + payload so the + // datagram branch ships a single `Bytes` without a second allocation. + let addr_size = match addr { + TargetAddr::IPv4(..) => 1 + 4 + 2, + TargetAddr::IPv6(..) => 1 + 16 + 2, + TargetAddr::Domain(d, _) => 1 + 1 + d.len() + 2, + }; + let header_overhead = 2 + 8 + addr_size; + let mut buf = BytesMut::with_capacity(header_overhead + if datagram { payload.len() } else { 0 }); + HeaderCodec.encode(Header::new(CmdType::Packet), &mut buf)?; + CmdCodec(CmdType::Packet).encode( + Command::Packet { + assoc_id, + pkt_id, + frag_total: 1, + frag_id: 0, + size: payload.len() as u16, + }, + &mut buf, + )?; + AddressCodec.encode(addr.to_owned().into(), &mut buf)?; + if datagram { + buf.extend_from_slice(&payload); + self.send_datagram(buf.freeze())?; + } else { + let mut send = self.open_uni().await?; + send.write_all(&buf).await?; + send.write_all(&payload).await?; + // Clean EOF — see note on `encode_and_send_uni`. + send.finish()?; + } + Ok(()) + } + + async fn drop_udp(&self, assoc_id: u16) -> Result<(), Error> { + let mut send = self.open_uni().await?; + let mut buf = BytesMut::with_capacity(4); + HeaderCodec.encode(Header::new(CmdType::Dissociate), &mut buf)?; + CmdCodec(CmdType::Dissociate).encode(Command::Dissociate { assoc_id }, &mut buf)?; + send.write_all(&buf).await?; + // Clean EOF — see note on `encode_and_send_uni`. + send.finish()?; + Ok(()) + } + + async fn send_heartbeat(&self) -> Result<(), Error> { + // 2 bytes: version + command. Sent as a datagram for lowest latency. + let mut buf = BytesMut::with_capacity(2); + HeaderCodec.encode(Header::new(CmdType::Heartbeat), &mut buf)?; + self.send_datagram(buf.freeze())?; + Ok(()) + } +} diff --git a/crates/wind-tuic/src/proto/mod.rs b/crates/wind-tuic/src/proto/mod.rs index 6385604..eaedec3 100644 --- a/crates/wind-tuic/src/proto/mod.rs +++ b/crates/wind-tuic/src/proto/mod.rs @@ -1,182 +1,20 @@ -//! TUIC protocol surface for the quinn backend. +//! TUIC protocol surface, backend-agnostic over [`wind_quic::QuicConnection`]. //! -//! The backend-agnostic wire codecs, [`ProtoError`], and the pure decode -//! helpers now live in the [`tuic_core::proto`] crate and are re-exported here -//! so existing `wind_tuic::proto::…` paths keep working. This module adds the -//! quinn-specific glue: [`ClientProtoExt`] and [`encode_and_send_uni`], plus -//! the quinn-coupled [`UdpStream`] (see [`udp_stream`]). +//! The on-wire codecs, [`ProtoError`], and the pure decode helpers live in the +//! [`tuic_core::proto`] crate and are re-exported here so existing +//! `wind_tuic::proto::…` paths keep working. The connection-coupled glue +//! ([`ClientProtoExt`], [`encode_and_send_uni`], [`UdpStream`]) is written once +//! against the `QuicConnection` trait — shared by both backends — and gated on +//! the `encode` feature (it builds wire frames via the encoders). pub use tuic_core::proto::*; +#[cfg(feature = "encode")] +mod client_proto; +#[cfg(feature = "encode")] mod udp_stream; -#[cfg(feature = "quinn")] -use std::future::Future; -#[cfg(feature = "quinn")] -use bytes::BytesMut; -#[cfg(feature = "quinn")] -use eyre::eyre; -#[cfg(feature = "quinn")] -use tokio_util::codec::Encoder; +#[cfg(feature = "encode")] +pub use client_proto::*; +#[cfg(feature = "encode")] pub use udp_stream::*; -#[cfg(feature = "quinn")] -use wind_core::{io::quinn::QuinnCompat, tcp::AbstractTcpStream, types::TargetAddr}; - -#[cfg(feature = "quinn")] -/// Helper function to encode and send data via unidirectional stream -pub async fn encode_and_send_uni( - conn: &quinn::Connection, - cmd_type: CmdType, - command: Command, - address: Option
, -) -> Result<(), Error> { - let mut buf = BytesMut::with_capacity(64); - HeaderCodec.encode(Header::new(cmd_type), &mut buf)?; - CmdCodec(cmd_type).encode(command, &mut buf)?; - if let Some(addr) = address { - AddressCodec.encode(addr, &mut buf)?; - } - let mut send = conn.open_uni().await?; - send.write_chunk(buf.into()).await?; - // Finish the stream so the peer's `read_to_end` (or equivalent EOF detector) - // observes a clean end-of-stream marker. Without this, dropping `send` - // resets the stream and the receiver sees a RESET_STREAM frame racing the - // payload, which intermittently breaks auth/dissociate/uni-UDP paths. - send.finish()?; - Ok(()) -} - -#[cfg(feature = "quinn")] -pub trait ClientProtoExt { - fn send_auth(&self, uuid: &uuid::Uuid, secret: &[u8]) -> impl Future> + Send; - fn send_heartbeat(&self) -> impl Future> + Send; - fn open_tcp( - &self, - addr: &TargetAddr, - stream: impl AbstractTcpStream, - ) -> impl Future> + Send; - fn send_udp( - &self, - assoc_id: u16, - pkt_id: u16, - addr: &TargetAddr, - packet: bytes::Bytes, - datagram: bool, - ) -> impl Future> + Send; - fn drop_udp(&self, assoc_id: u16) -> impl Future> + Send; -} - -#[cfg(feature = "quinn")] -impl ClientProtoExt for quinn::Connection { - async fn send_auth(&self, uuid: &uuid::Uuid, secret: &[u8]) -> Result<(), Error> { - // Generate the authentication token - let mut token = [0u8; 32]; - self.export_keying_material(&mut token, uuid.as_bytes(), secret) - .map_err(|_| eyre!("export_keying_material requested output length is too large."))?; - - // Create and encode the auth command - let auth_cmd = Command::Auth { uuid: *uuid, token }; - - // Pre-calculate the exact buffer capacity needed: 2 bytes for header + 16 bytes - // for UUID + 32 bytes for token - let mut buf = BytesMut::with_capacity(2 + 16 + 32); - - // Encode the header and command - HeaderCodec.encode(Header::new(CmdType::Auth), &mut buf)?; - CmdCodec(CmdType::Auth).encode(auth_cmd, &mut buf)?; - - // Open a unidirectional stream and send the data - let mut send = self.open_uni().await?; - send.write_chunk(buf.into()).await?; - // Mark end-of-stream so the server's authenticator sees a clean EOF - // rather than a reset on drop. See note on `encode_and_send_uni`. - send.finish()?; - - Ok(()) - } - - async fn open_tcp(&self, addr: &TargetAddr, mut stream: impl AbstractTcpStream) -> Result<(usize, usize), Error> { - let (mut send, recv) = self.open_bi().await?; - let mut buf = BytesMut::with_capacity(9); - HeaderCodec.encode(Header::new(CmdType::Connect), &mut buf)?; - CmdCodec(CmdType::Connect).encode(Command::Connect, &mut buf)?; - AddressCodec.encode(addr.to_owned().into(), &mut buf)?; - send.write_chunk(buf.into()).await?; - let (a, b, err) = wind_core::io::copy_io(&mut stream, &mut QuinnCompat::new(send, recv)).await; - // Guard clause: return early if there's an error - if let Some(e) = err { - return Err(e.into()); - } - Ok((a, b)) - } - - async fn send_udp( - &self, - assoc_id: u16, - pkt_id: u16, - addr: &TargetAddr, - payload: bytes::Bytes, - datagram: bool, - ) -> Result<(), Error> { - // Pre-size for header (2) + Packet command (8) + address + payload so - // the datagram branch can ship a single `Bytes` without a second - // allocation + memcpy. The old code built a `Chain` and - // then `copy_to_bytes`'d it, which exactly negated the point of using - // Chain (Chain cannot return a zero-copy slice when its two halves - // live in distinct `Bytes`). - let addr_size = match addr { - TargetAddr::IPv4(..) => 1 + 4 + 2, - TargetAddr::IPv6(..) => 1 + 16 + 2, - TargetAddr::Domain(d, _) => 1 + 1 + d.len() + 2, - }; - let header_overhead = 2 + 8 + addr_size; - let mut buf = BytesMut::with_capacity(header_overhead + if datagram { payload.len() } else { 0 }); - HeaderCodec.encode(Header::new(CmdType::Packet), &mut buf)?; - CmdCodec(CmdType::Packet).encode( - Command::Packet { - assoc_id, - pkt_id, - frag_total: 1, - frag_id: 0, - size: payload.len() as u16, - }, - &mut buf, - )?; - AddressCodec.encode(addr.to_owned().into(), &mut buf)?; - if datagram { - buf.extend_from_slice(&payload); - self.send_datagram(buf.freeze())?; - } else { - let mut send = self.open_uni().await?; - send.write_all_chunks(&mut [buf.into(), payload]).await?; - // Clean EOF — see note on `encode_and_send_uni`. - send.finish()?; - } - Ok(()) - } - - async fn drop_udp(&self, assoc_id: u16) -> Result<(), Error> { - let mut send = self.open_uni().await?; - let mut buf = BytesMut::with_capacity(4); - HeaderCodec.encode(Header::new(CmdType::Dissociate), &mut buf)?; - CmdCodec(CmdType::Dissociate).encode(Command::Dissociate { assoc_id }, &mut buf)?; - send.write_chunk(buf.into()).await?; - // Clean EOF — see note on `encode_and_send_uni`. - send.finish()?; - Ok(()) - } - - async fn send_heartbeat(&self) -> Result<(), Error> { - // Pre-allocate the exact size needed for the heartbeat: 2 bytes (version + - // command) - let mut buf = BytesMut::with_capacity(2); - - // Encode the heartbeat command header (no additional payload needed) - HeaderCodec.encode(Header::new(CmdType::Heartbeat), &mut buf)?; - - // Send it as a datagram for lowest latency - self.send_datagram(buf.freeze())?; - - Ok(()) - } -} diff --git a/crates/wind-tuic/src/proto/udp_stream.rs b/crates/wind-tuic/src/proto/udp_stream.rs index 7c9bde5..e943e87 100644 --- a/crates/wind-tuic/src/proto/udp_stream.rs +++ b/crates/wind-tuic/src/proto/udp_stream.rs @@ -8,17 +8,18 @@ use wind_core::{types::TargetAddr, udp::UdpPacket}; type UdpPacketTx = MAsyncTx>; use tuic_core::udp::{FragmentInfo, FragmentReassemblyBuffer, MAX_FRAGMENTS}; +use wind_quic::QuicConnection; use crate::proto::{Address, AddressCodec, ClientProtoExt as _, CmdCodec, CmdType, Command, Header, HeaderCodec}; -/// A TUIC UDP association over a quinn connection. +/// A TUIC UDP association over any [`QuicConnection`]. /// /// The fragment **reassembly** state machine lives in /// [`tuic_core::udp::FragmentReassemblyBuffer`]; this type owns the -/// quinn-coupled send path (datagram sizing, fragmentation, dispatch) and +/// connection-coupled send path (datagram sizing, fragmentation, dispatch) and /// bridges reassembled packets to a receive channel. -pub struct UdpStream { - connection: quinn::Connection, +pub struct UdpStream { + connection: C, assoc_id: u16, receive_tx: UdpPacketTx, next_pkt_id: AtomicU16, // Track packet IDs for fragmentation @@ -26,8 +27,8 @@ pub struct UdpStream { fragment_buffer: FragmentReassemblyBuffer, } -impl UdpStream { - pub fn new(connection: quinn::Connection, assoc_id: u16, receive_tx: UdpPacketTx) -> Self { +impl UdpStream { + pub fn new(connection: C, assoc_id: u16, receive_tx: UdpPacketTx) -> Self { Self { connection, assoc_id, diff --git a/crates/wind-tuic/src/quiche/inbound.rs b/crates/wind-tuic/src/quiche/inbound.rs new file mode 100644 index 0000000..d59f1c7 --- /dev/null +++ b/crates/wind-tuic/src/quiche/inbound.rs @@ -0,0 +1,172 @@ +//! TUIC inbound server — quiche (tokio-quiche) backend. +//! +//! A thin wrapper mirroring the former `wind-tuiche` public surface: it builds +//! a [`wind_quic::quiche`] listener (with live certificate rotation via +//! [`CertStore`]), accepts established connections, and drives each through the +//! backend-agnostic [`crate::server::serve_connection`]. All TUIC protocol +//! logic is shared with the quinn backend. + +use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration}; + +use tokio_util::sync::CancellationToken; +use tracing::{Instrument as _, info}; +use uuid::Uuid; +use wind_core::inbound::{AbstractInbound, InboundCallback}; +use wind_quic::{ + QuicConnection as _, ServerTlsConfig, + quiche::{CertStore, bind_server}, +}; + +use crate::{Result, quiche::utils::ConnectionOpts}; + +/// Authentication timeout for quiche connections. The peer must send its `Auth` +/// command within this window or the connection is closed (matches the quinn +/// backend's default). +const AUTH_TIMEOUT: Duration = Duration::from_secs(3); + +/// TUIC server using the quiche / tokio-quiche backend. +#[allow(dead_code)] +pub struct TuicheInbound { + listen_addr: SocketAddr, + users: HashMap, + opts: ConnectionOpts, + /// Path to the PEM-encoded TLS certificate chain. + cert_path: String, + /// Path to the PEM-encoded private key. + private_key_path: String, + /// Hot-swappable certificate served to every handshake; update via + /// [`TuicheInbound::cert_store`] for live rotation (e.g. ACME renewal). + cert_store: CertStore, +} + +impl TuicheInbound { + /// Create a new TUIC server builder. + pub fn builder() -> TuicheInboundBuilder { + TuicheInboundBuilder::new() + } + + /// Handle to the hot-swappable certificate store. Call + /// [`CertStore::update`] to rotate the served certificate live. + pub fn cert_store(&self) -> CertStore { + self.cert_store.clone() + } +} + +impl AbstractInbound for TuicheInbound { + async fn listen(&self, cb: &impl InboundCallback) -> eyre::Result<()> { + info!("Starting wind-tuic (quiche) inbound on {}", self.listen_addr); + + let tls = ServerTlsConfig::from_pem_paths(self.cert_path.clone(), self.private_key_path.clone()); + let transport = self.opts.to_transport(); + + let mut acceptor = bind_server(self.listen_addr, &tls, &transport, Some(&self.cert_store)).await?; + + let users = Arc::new(self.users.clone()); + // quiche has no external cancellation source here; each connection runs + // until the peer disconnects. + let root_cancel = CancellationToken::new(); + + info!("wind-tuic (quiche) listening loop started"); + + while let Some(conn) = acceptor.accept().await { + let remote = conn.peer_addr().unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0))); + let span = tracing::info_span!("conn", peer = %remote); + let users = users.clone(); + let cb = cb.clone(); + let cancel = root_cancel.child_token(); + tokio::spawn(crate::server::serve_connection(conn, remote, users, AUTH_TIMEOUT, cb, cancel).instrument(span)); + } + + Ok(()) + } +} + +/// Builder for [`TuicheInbound`]. +pub struct TuicheInboundBuilder { + listen_addr: Option, + users: HashMap, + cert_path: Option, + private_key_path: Option, + opts: ConnectionOpts, +} + +impl TuicheInboundBuilder { + /// Create a new builder. + pub fn new() -> Self { + Self { + listen_addr: None, + users: HashMap::new(), + cert_path: None, + private_key_path: None, + opts: ConnectionOpts::default(), + } + } + + /// Set the listen address. + pub fn listen_addr(mut self, addr: SocketAddr) -> Self { + self.listen_addr = Some(addr); + self + } + + /// Add a user. + pub fn user(mut self, uuid: Uuid, password: String) -> Self { + self.users.insert(uuid, password); + self + } + + /// Set the path to the PEM-encoded TLS certificate chain. + pub fn certificate_path(mut self, path: impl Into) -> Self { + self.cert_path = Some(path.into()); + self + } + + /// Set the path to the PEM-encoded private key. + pub fn private_key_path(mut self, path: impl Into) -> Self { + self.private_key_path = Some(path.into()); + self + } + + /// Set maximum idle time. + pub fn max_idle_time(mut self, time: Duration) -> Self { + self.opts.max_idle_timeout = time; + self + } + + /// Set connection options. + pub fn connection_opts(mut self, opts: ConnectionOpts) -> Self { + self.opts = opts; + self + } + + /// Build the server. Reads the certificate and key files to seed the + /// hot-swappable [`CertStore`]; both paths are required. + pub async fn build(self) -> Result { + let listen_addr = self.listen_addr.ok_or_else(|| eyre::eyre!("Listen address not set"))?; + let cert_path = self + .cert_path + .ok_or_else(|| eyre::eyre!("wind-tuic quiche inbound requires a certificate (set certificate_path)"))?; + let private_key_path = self + .private_key_path + .ok_or_else(|| eyre::eyre!("wind-tuic quiche inbound requires a private key (set private_key_path)"))?; + + let cert_pem = std::fs::read(&cert_path).map_err(|e| eyre::eyre!("reading certificate {}: {e}", cert_path))?; + let key_pem = + std::fs::read(&private_key_path).map_err(|e| eyre::eyre!("reading private key {}: {e}", private_key_path))?; + let cert_store = CertStore::from_pem(&cert_pem, &key_pem)?; + + Ok(TuicheInbound { + listen_addr, + users: self.users, + opts: self.opts, + cert_path, + private_key_path, + cert_store, + }) + } +} + +impl Default for TuicheInboundBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/wind-tuic/src/quiche/mod.rs b/crates/wind-tuic/src/quiche/mod.rs new file mode 100644 index 0000000..d3cca9b --- /dev/null +++ b/crates/wind-tuic/src/quiche/mod.rs @@ -0,0 +1,25 @@ +//! quiche (tokio-quiche) backend. +//! +//! Thin connection-provider wrapper around [`wind_quic::quiche`]: it constructs +//! the listener/connection and runs the shared TUIC protocol core +//! ([`crate::server`] / [`crate::client`] / [`crate::proto`]). Public types +//! mirror the former `wind-tuiche` crate so consumers only change their import +//! path. + +pub mod utils; + +pub use utils::{CongestionControl, ConnectionOpts, UdpRelayMode}; +/// Hot-swappable certificate store for live cert rotation (re-exported from +/// `wind-quic`, where the boring-based hot-reload hook lives). +pub use wind_quic::quiche::CertStore; + +#[cfg(feature = "server")] +pub mod inbound; + +#[cfg(feature = "client")] +pub mod outbound; + +#[cfg(feature = "server")] +pub use inbound::{TuicheInbound, TuicheInboundBuilder}; +#[cfg(feature = "client")] +pub use outbound::{TuicheOutbound, TuicheOutboundBuilder}; diff --git a/crates/wind-tuiche/src/outbound.rs b/crates/wind-tuic/src/quiche/outbound.rs similarity index 71% rename from crates/wind-tuiche/src/outbound.rs rename to crates/wind-tuic/src/quiche/outbound.rs index 9b42c2c..da91137 100644 --- a/crates/wind-tuiche/src/outbound.rs +++ b/crates/wind-tuic/src/quiche/outbound.rs @@ -1,18 +1,19 @@ -//! TUIC outbound client implementation backed by `tokio-quiche`. +//! TUIC outbound client — quiche backend. //! -//! The live connection path is not yet wired up: a future implementation will -//! establish the QUIC connection with [`tokio_quiche::quic::connect`] and then -//! drive the TUIC client state machine on top of it. For now this mirrors the -//! placeholder status of the previous quiche backend in `wind-tuic` — the -//! builder validates and stores the connection parameters. +//! The builder validates and stores connection parameters. A live client path +//! (handshake via [`wind_quic::quiche::connect`] driving the shared +//! [`crate::client`] / [`crate::proto`] code) is not yet wired up — this +//! mirrors the placeholder status carried over from `wind-tuiche`. The quiche +//! server is the production path; quiche clients are exercised via the quinn +//! client today. use std::{net::SocketAddr, time::Duration}; use uuid::Uuid; -use crate::{Result, utils::ConnectionOpts}; +use crate::{Result, quiche::utils::ConnectionOpts}; -/// TUIC client implementation using the `tokio-quiche` backend. +/// TUIC client using the quiche backend (configuration-only placeholder). #[allow(dead_code)] pub struct TuicheOutbound { server_addr: SocketAddr, @@ -23,7 +24,7 @@ pub struct TuicheOutbound { } impl TuicheOutbound { - /// Create a new TUIC client builder + /// Create a new TUIC client builder. pub fn builder() -> TuicheOutboundBuilder { TuicheOutboundBuilder::new() } @@ -42,7 +43,7 @@ pub struct TuicheOutboundBuilder { } impl TuicheOutboundBuilder { - /// Create a new builder + /// Create a new builder. pub fn new() -> Self { Self { server_addr: None, @@ -56,55 +57,55 @@ impl TuicheOutboundBuilder { } } - /// Set the server address + /// Set the server address. pub fn server_addr(mut self, addr: SocketAddr) -> Self { self.server_addr = Some(addr); self } - /// Set the server name (SNI) + /// Set the server name (SNI). pub fn server_name(mut self, name: String) -> Self { self.server_name = Some(name); self } - /// Set the user UUID + /// Set the user UUID. pub fn uuid(mut self, uuid: Uuid) -> Self { self.uuid = Some(uuid); self } - /// Set the password + /// Set the password. pub fn password(mut self, password: String) -> Self { self.password = Some(password); self } - /// Set maximum idle time + /// Set maximum idle time. pub fn max_idle_time(mut self, time: Duration) -> Self { self.max_idle_time = time; self } - /// Set connection timeout + /// Set connection timeout. pub fn connect_timeout(mut self, timeout: Duration) -> Self { self.connect_timeout = timeout; self } - /// Enable or disable certificate verification + /// Enable or disable certificate verification. pub fn verify_certificate(mut self, verify: bool) -> Self { self.verify_certificate = verify; self } - /// Set connection options + /// Set connection options. pub fn connection_opts(mut self, opts: ConnectionOpts) -> Self { self.opts = opts; self } - /// Build the client + /// Build the client. pub fn build(self) -> Result { let server_addr = self.server_addr.ok_or_else(|| eyre::eyre!("Server address not set"))?; let server_name = self.server_name.ok_or_else(|| eyre::eyre!("Server name not set"))?; diff --git a/crates/wind-tuic/src/quiche/utils.rs b/crates/wind-tuic/src/quiche/utils.rs new file mode 100644 index 0000000..d9c6d0c --- /dev/null +++ b/crates/wind-tuic/src/quiche/utils.rs @@ -0,0 +1,88 @@ +//! Configuration types for the quiche backend (mirrors the former +//! `wind-tuiche` surface). + +use std::time::Duration; + +use wind_quic::QuicCongestionControl; + +/// Congestion control algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CongestionControl { + #[default] + Cubic, + Bbr, + Reno, +} + +impl From for QuicCongestionControl { + fn from(cc: CongestionControl) -> Self { + match cc { + CongestionControl::Cubic => QuicCongestionControl::Cubic, + CongestionControl::Bbr => QuicCongestionControl::Bbr, + CongestionControl::Reno => QuicCongestionControl::Reno, + } + } +} + +/// UDP relay mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UdpRelayMode { + #[default] + Datagram, + Stream, +} + +/// Connection options. +#[derive(Debug, Clone)] +pub struct ConnectionOpts { + /// Maximum idle timeout. + pub max_idle_timeout: Duration, + /// Maximum concurrent bidirectional streams. + pub max_concurrent_bi_streams: u64, + /// Maximum concurrent unidirectional streams. + pub max_concurrent_uni_streams: u64, + /// Send window size. + pub send_window: u64, + /// Receive window size. + pub receive_window: u64, + /// Congestion control algorithm. + pub congestion_control: CongestionControl, + /// UDP relay mode. + pub udp_relay_mode: UdpRelayMode, + /// Enable 0-RTT. + pub enable_0rtt: bool, +} + +impl Default for ConnectionOpts { + fn default() -> Self { + Self { + max_idle_timeout: Duration::from_secs(30), + max_concurrent_bi_streams: 100, + max_concurrent_uni_streams: 100, + send_window: 8 * 1024 * 1024, // 8 MB + receive_window: 8 * 1024 * 1024, // 8 MB + congestion_control: CongestionControl::default(), + udp_relay_mode: UdpRelayMode::default(), + enable_0rtt: true, + } + } +} + +impl ConnectionOpts { + /// Translate into a backend-neutral [`wind_quic::TransportConfig`]. + pub(crate) fn to_transport(&self) -> wind_quic::TransportConfig { + wind_quic::TransportConfig { + max_concurrent_bidi_streams: self.max_concurrent_bi_streams, + max_concurrent_uni_streams: self.max_concurrent_uni_streams, + send_window: self.send_window, + receive_window: self.receive_window, + max_idle_timeout: Some(self.max_idle_timeout), + congestion: self.congestion_control.into(), + // TUIC's native UDP relay uses QUIC DATAGRAM frames (RFC 9221). + enable_datagram: matches!(self.udp_relay_mode, UdpRelayMode::Datagram), + enable_0rtt: self.enable_0rtt, + alpn: vec![b"h3".to_vec()], + ..Default::default() + } + } +} diff --git a/crates/wind-tuic/src/quinn/inbound.rs b/crates/wind-tuic/src/quinn/inbound.rs index 22bc785..11c74c9 100644 --- a/crates/wind-tuic/src/quinn/inbound.rs +++ b/crates/wind-tuic/src/quinn/inbound.rs @@ -1,37 +1,27 @@ -//! TUIC inbound server implementation (TCP/UDP over QUIC). -use std::{ - collections::HashMap, - net::SocketAddr, - pin::Pin, - sync::Arc, - task::{Context as TaskContext, Poll}, - time::Duration, -}; +//! TUIC inbound server — quinn backend. +//! +//! This is a thin wrapper: it builds the quinn endpoint (TLS, transport, and +//! congestion config), accepts/handshakes connections, then hands each +//! established connection to the backend-agnostic +//! [`crate::server::serve_connection`] (wrapped as a +//! [`wind_quic::quinn::QuinnConnection`]). All TUIC protocol logic lives in the +//! shared server core. + +use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration}; -use arc_swap::ArcSwapOption; use eyre::Context; -use moka::future::Cache; use quinn::{Endpoint, EndpointConfig, IdleTimeout, ServerConfig, TokioRuntime, TransportConfig, VarInt}; use rustls::{ ServerConfig as RustlsServerConfig, pki_types::{CertificateDer, PrivateKeyDer}, }; -use tokio::{ - io::{AsyncRead, AsyncWrite}, - sync::{Notify, mpsc}, -}; use tokio_util::sync::CancellationToken; use tracing::{Instrument, error, info, warn}; use uuid::Uuid; -use wind_core::{ - AbstractInbound, AppContext, InboundCallback, - udp::{UdpPacket, UdpStream as CoreUdpStream}, -}; +use wind_core::{AbstractInbound, AppContext, InboundCallback}; +use wind_quic::quinn::QuinnConnection; -use crate::{ - proto::{CmdType, Command}, - quinn::CongestionControl, -}; +use crate::quinn::CongestionControl; async fn spawn_logged(label: &str, fut: impl std::future::Future>) { if let Err(err) = fut.await { @@ -39,94 +29,6 @@ async fn spawn_logged(label: &str, fut: impl std::future::Future bool { - if ctx.uuid.load().is_some() { - return true; - } - if tokio::time::timeout(ctx.auth_timeout, ctx.auth_notify.notified()) - .await - .is_err() - { - return false; - } - ctx.uuid.load().is_some() -} - -/// Drive an `accept`-style call in a loop until the connection errors or -/// `cancel` fires. Each accepted value is handed to `handle`; the loop -/// exits on connection error (logged) or cancellation (silent). -async fn acceptor_loop( - cancel: CancellationToken, - label: &'static str, - mut accept: AccFn, - mut handle: HFn, -) where - AccFn: FnMut() -> AccFut, - HFn: FnMut(A) -> HFut, - AccFut: std::future::Future>, - HFut: std::future::Future, -{ - loop { - let result = tokio::select! { - _ = cancel.cancelled() => return, - r = accept() => r, - }; - match result { - Err(e) => { - // `ApplicationClosed`, `LocallyClosed`, `TimedOut`, and other - // non-error connection terminations are normal lifecycle - // events; treating them as ERR muddied operator logs with - // red-herring entries on every legitimate disconnect. Log at - // debug instead and let the connection wind down quietly. - if matches!( - &e, - quinn::ConnectionError::ApplicationClosed(_) - | quinn::ConnectionError::LocallyClosed - | quinn::ConnectionError::TimedOut - ) { - tracing::debug!("{label} loop ending after benign connection close: {e:?}"); - } else { - error!("{label} error: {e:?}"); - } - return; - } - Ok(v) => handle(v).await, - } - } -} - -struct QuicBidiStream { - send: quinn::SendStream, - recv: quinn::RecvStream, -} - -impl AsyncRead for QuicBidiStream { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - buf: &mut tokio::io::ReadBuf<'_>, - ) -> Poll> { - Pin::new(&mut self.recv).poll_read(cx, buf) - } -} - -impl AsyncWrite for QuicBidiStream { - fn poll_write(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll> { - Pin::new(&mut self.send).poll_write(cx, buf).map_err(std::io::Error::other) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.send).poll_flush(cx).map_err(std::io::Error::other) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - Pin::new(&mut self.send).poll_shutdown(cx).map_err(std::io::Error::other) - } -} - pub struct TuicInboundOpts { pub listen_addr: SocketAddr, @@ -262,9 +164,7 @@ impl TuicInbound { /// Build the QUIC congestion-controller factory selected by /// [`TuicInboundOpts::congestion_control`], applying the configured initial - /// window. Previously the server always used quinn's default (CUBIC with a - /// small initial window) and the operator's congestion-control settings - /// were silently ignored. + /// window. fn congestion_controller_factory(&self) -> Arc { let iw = self.opts.initial_window; match self.opts.congestion_control { @@ -304,12 +204,9 @@ impl AbstractInbound for TuicInbound { let users = Arc::new(self.opts.users.clone()); loop { - // `endpoint.accept()` returns `None` once the endpoint is shut down - // (e.g. the underlying socket closed). Without an `else =>` arm the - // `tokio::select!` macro panics when every branch is disabled — - // `Some(...) = endpoint.accept()` is the only data branch and it - // goes from "pending" to "disabled" the instant `accept()` yields - // `None`. Catch that as a normal shutdown. + // `endpoint.accept()` returns `None` once the endpoint is shut down; + // the `else =>` arm catches that as a normal shutdown so the + // `tokio::select!` doesn't panic when every branch is disabled. tokio::select! { _ = self.cancel.cancelled() => { info!("TUIC server shutting down"); @@ -341,46 +238,8 @@ impl AbstractInbound for TuicInbound { } } -struct InboundCtx { - conn: quinn::Connection, - uuid: ArcSwapOption, - auth_notify: Arc, - users: Arc>, - auth_timeout: Duration, - udp_sessions: Cache, - /// Parent of every per-UDP-session cancel token. Cancelling this tears - /// down all live bridge tasks at once (used when the parent connection - /// terminates). - udp_root_cancel: CancellationToken, -} - -/// Per-UDP-session state stored in the LRU cache. -/// -/// `cancel` is a child of `InboundCtx::udp_root_cancel` and is wired into the -/// three bridge tasks via `tokio::select!`. When the session is evicted — -/// either by an explicit `Dissociate` command via `handle_dissociate` or by -/// LRU/capacity pressure — the moka `async_eviction_listener` calls -/// `cancel.cancel()`, causing all bridge tasks to exit promptly. Without this -/// the tasks captured strong `Arc` clones AND owned the channel -/// halves they recv from, forming a self-sustaining cycle that survived the -/// cache eviction. The session "removed" from the cache but the tasks kept -/// running until the connection died, letting a peer that cycles assoc_ids -/// pile up unbounded background work. -#[derive(Clone)] -struct UdpSession { - tuic_stream: Arc, - cancel: CancellationToken, -} - -/// Per-connection ceiling on concurrent UDP associations. The previous -/// `u16::MAX` covered the entire association-id space — every assoc id had a -/// reserved cache slot and each session spawned three tasks plus four -/// channels, so a single authenticated peer could pin ~200k tasks at -/// O(few MB) state each. 1024 is plenty for legitimate clients (the spec's -/// own example uses single-digit assoc_ids) while keeping per-connection -/// memory bounded. -const MAX_UDP_SESSIONS_PER_CONN: u64 = 1024; - +/// Complete the quinn handshake (incl. optional 0-RTT) for one incoming +/// connection, then drive it through the backend-agnostic server core. async fn handle_connection( incoming: quinn::Incoming, users: Arc>, @@ -425,633 +284,8 @@ async fn handle_connection( conn }; - let udp_root_cancel = cancel.child_token(); - - // Eviction listener fires for both explicit `remove()` (via - // `handle_dissociate`) and capacity/LRU pressure. Cancel the session's - // token so the bridge tasks unstick from their channel waits and shut - // down promptly. Using the async listener so we can be cheap & infallible - // — just toggle the token. - let eviction_cancel = move |_k: Arc, v: UdpSession, _cause| -> moka::notification::ListenerFuture { - Box::pin(async move { - v.cancel.cancel(); - }) - }; - let udp_sessions = Cache::builder() - .max_capacity(MAX_UDP_SESSIONS_PER_CONN) - .async_eviction_listener(eviction_cancel) - .build(); - - let connection = Arc::new(InboundCtx { - conn: conn.clone(), - uuid: ArcSwapOption::empty(), - auth_notify: Arc::new(Notify::new()), - users, - auth_timeout, - udp_sessions, - udp_root_cancel, - }); - - // Spawn authentication timeout task. - let conn_auth = connection.clone(); - let auth_cancel = cancel.clone(); - tokio::spawn( - async move { - tokio::select! { - _ = tokio::time::sleep(auth_timeout) => { - if conn_auth.uuid.load().is_none() { - warn!("Connection from {} authentication timeout", remote_addr); - conn_auth.conn.close(VarInt::from_u32(0), b"auth timeout"); - } - } - _ = auth_cancel.cancelled() => {} - _ = conn_auth.conn.closed() => {} - } - } - .in_current_span(), - ); - - // One cancellation token shared by all acceptor tasks. Cancelling it - // from the parent stops every acceptor at once; we also fire it after - // the parent loop exits so InboundCtx (with its per-connection UDP - // session cache) can be dropped instead of leaking until server - // shutdown. - let acceptor_cancel = cancel.child_token(); - - // Datagram acceptor. Pre-auth datagrams are handled inline (serially) - // so an unauthenticated peer can't spawn unbounded tasks parked on - // `auth_notify`; once authed, each datagram is dispatched in parallel - // so a slow outbound queue can't block the read loop. - { - let conn = connection.clone(); - let cb = callback.clone(); - let dg_cancel = acceptor_cancel.clone(); - tokio::spawn( - async move { - acceptor_loop( - dg_cancel, - "Read datagram", - || conn.conn.read_datagram(), - |datagram| { - let conn = conn.clone(); - let cb = cb.clone(); - async move { - if conn.uuid.load().is_some() { - tokio::spawn( - spawn_logged("Datagram", handle_datagram(conn, datagram, cb)) - .instrument(tracing::debug_span!("datagram")), - ); - } else if let Err(e) = handle_datagram(conn, datagram, cb).await { - error!("Datagram error: {e:?}"); - } - } - }, - ) - .await; - } - .in_current_span(), - ); - } - - // Uni stream acceptor. - { - let conn = connection.clone(); - let cb = callback.clone(); - let uni_cancel = acceptor_cancel.clone(); - tokio::spawn( - async move { - acceptor_loop( - uni_cancel, - "Accept uni", - || conn.conn.accept_uni(), - |recv| { - let conn = conn.clone(); - let cb = cb.clone(); - async move { - tokio::spawn( - spawn_logged("Uni stream", handle_uni_stream(conn, recv, cb)) - .instrument(tracing::debug_span!("uni_stream")), - ); - } - }, - ) - .await; - } - .in_current_span(), - ); - } - - // Bi stream acceptor. - { - let conn = connection.clone(); - let cb = callback.clone(); - let bi_cancel = acceptor_cancel.clone(); - tokio::spawn( - async move { - acceptor_loop( - bi_cancel, - "Accept bi", - || conn.conn.accept_bi(), - |(send, recv)| { - let conn = conn.clone(); - let cb = cb.clone(); - async move { - tokio::spawn( - spawn_logged("Bi stream", handle_bi_stream(conn, send, recv, cb)) - .instrument(tracing::debug_span!("bi_stream")), - ); - } - }, - ) - .await; - } - .in_current_span(), - ); - } - - // Exit on either server shutdown or peer disconnect. Without the - // `conn.closed()` arm the handler would block on `cancel` until the - // whole server stops, keeping InboundCtx and every UDP bridge task - // alive long after the QUIC connection is gone. - tokio::select! { - _ = cancel.cancelled() => { - connection.conn.close(VarInt::from_u32(0), b"server shutdown"); - info!("Connection from {} closed by server shutdown", remote_addr); - } - _ = connection.conn.closed() => { - info!("Connection from {} closed", remote_addr); - } - } - acceptor_cancel.cancel(); - - Ok(()) -} - -async fn handle_uni_stream( - ctx: Arc, - mut recv: quinn::RecvStream, - callback: C, -) -> eyre::Result<()> { - let mut header_buf = [0u8; 2]; - recv.read_exact(&mut header_buf) - .await - .map_err(|e| eyre::eyre!("Failed to read uni stream header: {}", e))?; - let header = crate::proto::decode_header(&mut &header_buf[..], "uni stream")?; - - match header.command { - CmdType::Auth => { - let mut body = [0u8; 16 + 32]; - recv.read_exact(&mut body) - .await - .map_err(|e| eyre::eyre!("Failed to read auth body: {}", e))?; - let cmd = crate::proto::decode_command(CmdType::Auth, &mut &body[..], "uni stream")?; - if let Command::Auth { uuid, token } = cmd { - handle_auth(&ctx, uuid, token).await?; - } - } - cmd_type => { - if !ensure_authed(&ctx).await { - warn!( - command = ?cmd_type, - "Uni stream rejected: not authenticated within {:?}", - ctx.auth_timeout - ); - return Ok(()); - } - - match cmd_type { - CmdType::Packet => { - let mut cmd_body = [0u8; 8]; - recv.read_exact(&mut cmd_body) - .await - .map_err(|e| eyre::eyre!("Failed to read packet command: {}", e))?; - let cmd = crate::proto::decode_command(CmdType::Packet, &mut &cmd_body[..], "uni stream")?; - let Command::Packet { - assoc_id, - pkt_id, - frag_total, - frag_id, - size, - } = cmd - else { - unreachable!("decode_command(Packet, ..) must return Command::Packet"); - }; - - // Read address (capped at ~258 bytes). - let addr = read_address_exact(&mut recv) - .await - .wrap_err("Failed to read uni stream packet address")?; - - // Payload bounded by u16 size (≤ 65535). - let mut payload = vec![0u8; size as usize]; - if size > 0 { - recv.read_exact(&mut payload) - .await - .map_err(|e| eyre::eyre!("Failed to read packet payload: {}", e))?; - } - let payload = bytes::Bytes::from(payload); - - let target_addr = match crate::proto::address_to_target(addr) { - Ok(t) => t, - Err(_) => wind_core::types::TargetAddr::IPv4(std::net::Ipv4Addr::UNSPECIFIED, 0), - }; - handle_udp_packet(&ctx, assoc_id, pkt_id, frag_total, frag_id, target_addr, payload, &callback).await?; - } - CmdType::Dissociate => { - let mut body = [0u8; 2]; - recv.read_exact(&mut body) - .await - .map_err(|e| eyre::eyre!("Failed to read dissociate body: {}", e))?; - let cmd = crate::proto::decode_command(CmdType::Dissociate, &mut &body[..], "uni stream")?; - if let Command::Dissociate { assoc_id } = cmd { - handle_dissociate(&ctx, assoc_id).await?; - } - } - CmdType::Heartbeat => { - tracing::trace!("Received heartbeat from {:?}", ctx.uuid.load()); - } - other => { - warn!("Unexpected command on uni stream: {:?}", other); - } - } - } - } - - Ok(()) -} - -async fn handle_bi_stream( - connection: Arc, - send: quinn::SendStream, - mut recv: quinn::RecvStream, - callback: C, -) -> eyre::Result<()> { - if !ensure_authed(&connection).await { - warn!("Bi stream rejected: not authenticated within {:?}", connection.auth_timeout); - return Ok(()); - } - - let mut header_buf = [0u8; 2]; - recv.read_exact(&mut header_buf) - .await - .map_err(|e| eyre::eyre!("Failed to read header: {}", e))?; - let mut buf = &header_buf[..]; - - let header = crate::proto::decode_header(&mut buf, "bi stream")?; - - match header.command { - CmdType::Connect => { - let _cmd = crate::proto::decode_command(CmdType::Connect, &mut [].as_ref(), "bi stream")?; - - let addr = read_address_exact(&mut recv) - .await - .wrap_err("Failed to read connect address")?; - - let target_addr = crate::proto::address_to_target(addr)?; - - info!(target = %target_addr, "TCP connect"); - - let stream = QuicBidiStream { send, recv }; - - callback.handle_tcpstream(target_addr, stream).await?; - } - _ => { - warn!("Unexpected command on bi stream: {:?}", header.command); - } - } - - Ok(()) -} - -async fn handle_datagram(connection: Arc, data: bytes::Bytes, callback: C) -> eyre::Result<()> { - if !ensure_authed(&connection).await { - warn!("Datagram rejected: not authenticated within {:?}", connection.auth_timeout); - return Ok(()); - } - - let mut buf = data; - - let header = crate::proto::decode_header(&mut buf, "datagram")?; - - match header.command { - CmdType::Packet => { - let cmd = crate::proto::decode_command(CmdType::Packet, &mut buf, "datagram")?; - - if let Command::Packet { - assoc_id, - pkt_id, - frag_total, - frag_id, - size, - } = cmd - { - let addr = crate::proto::decode_address(&mut buf, "datagram packet")?; - if buf.len() < size as usize { - return Err(eyre::eyre!("datagram payload truncated: need {}, have {}", size, buf.len())); - } - let payload = buf.split_to(size as usize); - - let target_addr = match crate::proto::address_to_target(addr) { - Ok(t) => t, - Err(_) => wind_core::types::TargetAddr::IPv4(std::net::Ipv4Addr::UNSPECIFIED, 0), - }; - - tracing::debug!( - assoc_id, - pkt_id, - frag = format_args!("{}/{}", frag_id, frag_total), - target = %target_addr, - payload_len = payload.len(), - "UDP datagram received", - ); - - handle_udp_packet( - &connection, - assoc_id, - pkt_id, - frag_total, - frag_id, - target_addr, - payload, - &callback, - ) - .await?; - } - } - CmdType::Heartbeat => { - tracing::trace!("UDP heartbeat received"); - } - other => { - tracing::debug!(command = ?other, "unexpected datagram command"); - } - } - - Ok(()) -} - -async fn handle_auth(connection: &InboundCtx, uuid: Uuid, token: [u8; 32]) -> eyre::Result<()> { - // Look the user up, but never short-circuit on an unknown UUID — that would - // give an attacker both a timing oracle (skipped keying-material export) and - // an error-message oracle that reveals whether a UUID exists. Instead, always - // run the export against either the real password or a fixed dummy, and - // always run a constant-time comparison; both failure paths return the same - // generic error. - const DUMMY_PASSWORD: &[u8] = b"\x00\x00\x00\x00\x00\x00\x00\x00"; - let (password_bytes, user_known) = match connection.users.get(&uuid) { - Some(pw) => (pw.as_bytes(), true), - None => (DUMMY_PASSWORD, false), - }; - - let mut expected_token = [0u8; 32]; - let export_ok = connection - .conn - .export_keying_material(&mut expected_token, uuid.as_bytes(), password_bytes) - .is_ok(); - - // Constant-time comparison: never short-circuit on first differing byte. - let mut diff: u8 = 0; - for (a, b) in token.iter().zip(expected_token.iter()) { - diff |= a ^ b; - } - let token_ok = diff == 0; - - if !(user_known && export_ok && token_ok) { - // Single generic error for "unknown user", "bad token", and - // "export failed" — do not leak which one triggered. - return Err(eyre::eyre!("Invalid authentication")); - } - - connection.uuid.store(Some(Arc::new(uuid))); - connection.auth_notify.notify_waiters(); - info!(uuid = %uuid, "authenticated"); - - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -async fn handle_udp_packet( - ctx: &Arc, - assoc_id: u16, - pkt_id: u16, - frag_total: u8, - frag_id: u8, - target_addr: wind_core::types::TargetAddr, - payload: bytes::Bytes, - callback: &C, -) -> eyre::Result<()> { - if frag_total == 0 { - tracing::debug!(assoc_id, pkt_id, "dropping packet with frag_total=0"); - return Ok(()); - } - - let tuic_stream = get_or_create_session(ctx, assoc_id, callback).await?; - - if frag_total == 1 { - tracing::trace!(assoc_id, pkt_id, target = %target_addr, len = payload.len(), "UDP packet → outbound"); - tuic_stream - .receive_packet(UdpPacket { - source: None, - target: target_addr, - payload, - }) - .await?; - } else { - if let Some(complete) = tuic_stream - .process_fragment(assoc_id, pkt_id, frag_total, frag_id, payload, None, target_addr) - .await - { - tracing::debug!(assoc_id, pkt_id, frag_total, target = %complete.target, len = complete.payload.len(), "UDP packet reassembled → outbound"); - tuic_stream.receive_packet(complete).await?; - } - } - - Ok(()) -} - -/// Get an existing UDP session for `assoc_id` or create a new one. -async fn get_or_create_session( - ctx: &Arc, - assoc_id: u16, - callback: &C, -) -> eyre::Result> { - if let Some(session) = ctx.udp_sessions.get(&assoc_id).await { - return Ok(session.tuic_stream.clone()); - } - - let cb = callback.clone(); - let conn = ctx.conn.clone(); - let session_cancel = ctx.udp_root_cancel.child_token(); - let session = ctx - .udp_sessions - .entry(assoc_id) - .or_insert_with(async { - info!("Creating new UDP session for assoc_id {}", assoc_id); - - let (reassembled_tx, reassembled_rx) = crossfire::mpmc::bounded_async::(128); - - let (to_outbound_tx, to_outbound_rx) = mpsc::channel::(100); - let (from_outbound_tx, mut from_outbound_rx) = mpsc::channel::(100); - - let tuic_stream = Arc::new(crate::proto::UdpStream::new(conn, assoc_id, reassembled_tx)); - - let outbound_stream = CoreUdpStream { - tx: from_outbound_tx, - rx: to_outbound_rx, - }; - - // Bridge reassembled packets from quinn -> outbound with backpressure. - let cancel_a = session_cancel.clone(); - tokio::spawn( - async move { - loop { - tokio::select! { - biased; - _ = cancel_a.cancelled() => break, - res = reassembled_rx.recv() => { - let packet = match res { - Ok(p) => p, - Err(_) => break, - }; - tokio::select! { - _ = cancel_a.cancelled() => break, - send_res = tokio::time::timeout(Duration::from_secs(5), to_outbound_tx.send(packet)) => { - match send_res { - Ok(Ok(())) => {} - Ok(Err(mpsc::error::SendError(_))) => break, - Err(_) => { - warn!("UDP outbound queue full (assoc {}), dropping packet after 5s", assoc_id); - } - } - } - } - } - } - } - } - .in_current_span(), - ); - - { - let response_stream = tuic_stream.clone(); - let cancel_b = session_cancel.clone(); - tokio::spawn( - async move { - loop { - tokio::select! { - biased; - _ = cancel_b.cancelled() => break, - maybe_packet = from_outbound_rx.recv() => { - let Some(packet) = maybe_packet else { break }; - if let Err(e) = response_stream.send_packet(packet).await { - warn!("Failed to send UDP response (assoc {}): {}", assoc_id, e); - break; - } - } - } - } - } - .in_current_span(), - ); - } - - { - let cancel_c = session_cancel.clone(); - tokio::spawn( - async move { - // `handle_udpstream` typically runs forever; race the - // session-cancel token so it exits with the rest of - // the session instead of holding the callback's - // resources hostage after eviction/dissociate. - tokio::select! { - _ = cancel_c.cancelled() => {} - res = cb.handle_udpstream(outbound_stream) => { - if let Err(e) = res { - error!("UDP stream handler error (assoc {}): {}", assoc_id, e); - } - } - } - } - .in_current_span(), - ); - } - - UdpSession { - tuic_stream, - cancel: session_cancel, - } - }) - .await; - - Ok(session.into_value().tuic_stream.clone()) -} - -async fn read_address_exact(recv: &mut quinn::RecvStream) -> eyre::Result { - let mut type_byte = [0u8; 1]; - recv.read_exact(&mut type_byte) - .await - .map_err(|e| eyre::eyre!("Failed to read address type: {}", e))?; - - match type_byte[0] { - 0xFF => Ok(crate::proto::Address::None), - 0x01 => { - let mut ip_bytes = [0u8; 4]; - recv.read_exact(&mut ip_bytes) - .await - .map_err(|e| eyre::eyre!("Failed to read IPv4 address: {}", e))?; - let mut port_buf = [0u8; 2]; - recv.read_exact(&mut port_buf) - .await - .map_err(|e| eyre::eyre!("Failed to read IPv4 port: {}", e))?; - Ok(crate::proto::Address::IPv4( - std::net::Ipv4Addr::from(ip_bytes), - u16::from_be_bytes(port_buf), - )) - } - 0x02 => { - let mut ip_bytes = [0u8; 16]; - recv.read_exact(&mut ip_bytes) - .await - .map_err(|e| eyre::eyre!("Failed to read IPv6 address: {}", e))?; - let mut port_buf = [0u8; 2]; - recv.read_exact(&mut port_buf) - .await - .map_err(|e| eyre::eyre!("Failed to read IPv6 port: {}", e))?; - Ok(crate::proto::Address::IPv6( - std::net::Ipv6Addr::from(ip_bytes), - u16::from_be_bytes(port_buf), - )) - } - 0x00 => { - // AddressType::Domain — 1-byte length + bytes + 2-byte port - let mut len_byte = [0u8; 1]; - recv.read_exact(&mut len_byte) - .await - .map_err(|e| eyre::eyre!("Failed to read domain length: {}", e))?; - let domain_len = len_byte[0] as usize; - - // Read directly into a Vec sized to the exact length so we can - // hand it off to `String::from_utf8` without an extra copy. - let mut domain = vec![0u8; domain_len]; - recv.read_exact(&mut domain) - .await - .map_err(|e| eyre::eyre!("Failed to read domain address: {}", e))?; - - let mut port_buf = [0u8; 2]; - recv.read_exact(&mut port_buf) - .await - .map_err(|e| eyre::eyre!("Failed to read domain port: {}", e))?; - let port = u16::from_be_bytes(port_buf); - - let domain_str = String::from_utf8(domain).map_err(|_| eyre::eyre!("Invalid UTF-8 domain address"))?; - Ok(crate::proto::Address::Domain(domain_str, port)) - } - t => Err(eyre::eyre!("Unknown address type byte 0x{:02x}", t)), - } -} + // Hand the established connection to the shared, backend-agnostic core. + crate::server::serve_connection(QuinnConnection::new(conn), remote_addr, users, auth_timeout, callback, cancel).await; -/// Handle UDP dissociate -async fn handle_dissociate(connection: &InboundCtx, assoc_id: u16) -> eyre::Result<()> { - connection.udp_sessions.remove(&assoc_id).await; - info!("Dissociated UDP session {}", assoc_id); Ok(()) } diff --git a/crates/wind-tuic/src/quinn/mod.rs b/crates/wind-tuic/src/quinn/mod.rs index aa69670..89b8306 100644 --- a/crates/wind-tuic/src/quinn/mod.rs +++ b/crates/wind-tuic/src/quinn/mod.rs @@ -1,4 +1,3 @@ -mod task; pub mod tls; pub mod utils; diff --git a/crates/wind-tuic/src/quinn/outbound.rs b/crates/wind-tuic/src/quinn/outbound.rs index 81d00cb..38a2d23 100644 --- a/crates/wind-tuic/src/quinn/outbound.rs +++ b/crates/wind-tuic/src/quinn/outbound.rs @@ -12,11 +12,12 @@ use tokio_util::sync::CancellationToken; use tracing::{Instrument as _, info, warn}; use uuid::Uuid; use wind_core::{AbstractOutbound, AppContext, tcp::AbstractTcpStream, types::TargetAddr}; +use wind_quic::quinn::QuinnConnection; use crate::{ Error, + client::ClientTaskExt, proto::{ClientProtoExt, UdpStream as TuicUdpStream}, - quinn::task::ClientTaskExt, }; pub struct TuicOutboundOpts { @@ -37,10 +38,10 @@ pub struct TuicOutbound { pub peer_addr: SocketAddr, pub sni: String, pub opts: TuicOutboundOpts, - pub connection: quinn::Connection, + pub connection: QuinnConnection, pub udp_assoc_counter: AtomicU16, pub token: CancellationToken, - pub udp_session: Cache>, + pub udp_session: Cache>>, } impl TuicOutbound { @@ -83,10 +84,13 @@ impl TuicOutbound { let endpoint = quinn::Endpoint::new(quinn::EndpointConfig::default(), None, socket, Arc::new(TokioRuntime))?; endpoint.set_default_client_config(client_config); - let connection = endpoint + let raw_conn = endpoint .connect(peer_addr, &server_name) .map_err(|e| eyre::eyre!("Failed to connect to {} ({}): {}", peer_addr, server_name, e))? .await?; + // Wrap in the backend-agnostic handle so the shared client/proto code + // (auth, heartbeat, TCP/UDP relay) drives it. + let connection = QuinnConnection::new(raw_conn); connection.send_auth(&opts.auth.0, &opts.auth.1).await?; diff --git a/crates/wind-tuic/src/server/mod.rs b/crates/wind-tuic/src/server/mod.rs new file mode 100644 index 0000000..f3f5535 --- /dev/null +++ b/crates/wind-tuic/src/server/mod.rs @@ -0,0 +1,772 @@ +//! Backend-agnostic TUIC server core. +//! +//! This is the TUIC inbound protocol logic — auth handshake, the three parallel +//! accept loops (datagram / uni / bi), command dispatch, and per-association +//! UDP session management — written **once** generic over +//! [`wind_quic::QuicConnection`]. Both the quinn and quiche backends construct +//! their endpoint, accept an established connection, and hand it to +//! [`serve_connection`]; everything above the QUIC handle is shared. +//! +//! It is the generalization of the former quinn-only `quinn::inbound` server +//! (the reference behavior) and the replacement for the bespoke `wind-tuiche` +//! driver. + +use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration}; + +use arc_swap::ArcSwapOption; +use eyre::Context as _; +use moka::future::Cache; +use tokio::{ + io::{AsyncRead, AsyncReadExt as _}, + sync::{Notify, mpsc}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{Instrument as _, error, info, warn}; +use uuid::Uuid; +use wind_core::{ + InboundCallback, + udp::{UdpPacket, UdpStream as CoreUdpStream}, +}; +use wind_quic::{QuicConnection, QuicError}; + +use crate::proto::{CmdType, Command, UdpStream}; + +async fn spawn_logged(label: &str, fut: impl std::future::Future>) { + if let Err(err) = fut.await { + error!("{label} error: {err:?}"); + } +} + +/// Wait for the connection to be authenticated. Returns `true` once a UUID is +/// set; returns `false` if the auth timeout elapses first. Callers that get +/// `false` must drop the request. +async fn ensure_authed(ctx: &InboundCtx) -> bool { + if ctx.uuid.load().is_some() { + return true; + } + if tokio::time::timeout(ctx.auth_timeout, ctx.auth_notify.notified()) + .await + .is_err() + { + return false; + } + ctx.uuid.load().is_some() +} + +/// Drive an `accept`-style call in a loop until the connection errors or +/// `cancel` fires. Each accepted value is handed to `handle`; the loop exits on +/// connection error (benign closes logged at debug) or cancellation (silent). +async fn acceptor_loop( + cancel: CancellationToken, + label: &'static str, + mut accept: AccFn, + mut handle: HFn, +) where + AccFn: FnMut() -> AccFut, + HFn: FnMut(A) -> HFut, + AccFut: std::future::Future>, + HFut: std::future::Future, +{ + loop { + let result = tokio::select! { + _ = cancel.cancelled() => return, + r = accept() => r, + }; + match result { + Err(e) => { + // `ApplicationClosed`, `LocallyClosed`, and `TimedOut` are normal + // lifecycle events; log at debug instead of error so legitimate + // disconnects don't muddy operator logs. + if matches!( + &e, + QuicError::ApplicationClosed { .. } | QuicError::LocallyClosed | QuicError::TimedOut + ) { + tracing::debug!("{label} loop ending after benign connection close: {e:?}"); + } else { + error!("{label} error: {e:?}"); + } + return; + } + Ok(v) => handle(v).await, + } + } +} + +struct InboundCtx { + conn: C, + uuid: ArcSwapOption, + auth_notify: Arc, + users: Arc>, + auth_timeout: Duration, + udp_sessions: Cache>, + /// Parent of every per-UDP-session cancel token. Cancelling this tears down + /// all live bridge tasks at once (used when the parent connection + /// terminates). + udp_root_cancel: CancellationToken, +} + +/// Per-UDP-session state stored in the LRU cache. +/// +/// `cancel` is a child of `InboundCtx::udp_root_cancel` and is wired into the +/// three bridge tasks via `tokio::select!`. When the session is evicted — +/// either by an explicit `Dissociate` or by LRU/capacity pressure — the moka +/// `async_eviction_listener` cancels it, so the bridge tasks exit promptly +/// instead of forming a self-sustaining cycle that outlives the cache entry. +struct UdpSession { + tuic_stream: Arc>, + cancel: CancellationToken, +} + +impl Clone for UdpSession { + fn clone(&self) -> Self { + Self { + tuic_stream: self.tuic_stream.clone(), + cancel: self.cancel.clone(), + } + } +} + +/// Per-connection ceiling on concurrent UDP associations. Bounds per-connection +/// memory: each session spawns three tasks plus channels, so an unbounded space +/// would let one authenticated peer pin a large amount of background work. +const MAX_UDP_SESSIONS_PER_CONN: u64 = 1024; + +/// Drive an established TUIC connection: spawn the auth-timeout guard and the +/// datagram/uni/bi accept loops, then run until the peer disconnects or +/// `cancel` fires. Backend-agnostic — both backends call this after their +/// handshake. +pub async fn serve_connection( + conn: C, + remote_addr: SocketAddr, + users: Arc>, + auth_timeout: Duration, + callback: CB, + cancel: CancellationToken, +) where + C: QuicConnection, + CB: InboundCallback, +{ + let udp_root_cancel = cancel.child_token(); + + // Eviction listener fires for both explicit `remove()` (via Dissociate) and + // capacity/LRU pressure. Cancel the session's token so the bridge tasks + // unstick from their channel waits and shut down promptly. + let eviction_cancel = move |_k: Arc, v: UdpSession, _cause| -> moka::notification::ListenerFuture { + Box::pin(async move { + v.cancel.cancel(); + }) + }; + let udp_sessions = Cache::builder() + .max_capacity(MAX_UDP_SESSIONS_PER_CONN) + .async_eviction_listener(eviction_cancel) + .build(); + + let connection = Arc::new(InboundCtx { + conn, + uuid: ArcSwapOption::empty(), + auth_notify: Arc::new(Notify::new()), + users, + auth_timeout, + udp_sessions, + udp_root_cancel, + }); + + // Authentication timeout: close the connection if no UUID is set in time. + { + let conn_auth = connection.clone(); + let auth_cancel = cancel.clone(); + tokio::spawn( + async move { + tokio::select! { + _ = tokio::time::sleep(auth_timeout) => { + if conn_auth.uuid.load().is_none() { + warn!("Connection from {} authentication timeout", remote_addr); + conn_auth.conn.close(0, b"auth timeout"); + } + } + _ = auth_cancel.cancelled() => {} + _ = conn_auth.conn.closed() => {} + } + } + .in_current_span(), + ); + } + + // One cancellation token shared by all acceptor tasks; fired after the + // parent loop exits so `InboundCtx` (with its per-connection UDP session + // cache) is dropped instead of leaking until server shutdown. + let acceptor_cancel = cancel.child_token(); + + // Datagram acceptor. Pre-auth datagrams are handled inline (serially) so an + // unauthenticated peer can't spawn unbounded tasks parked on `auth_notify`; + // once authed, each datagram is dispatched in parallel so a slow outbound + // queue can't block the read loop. + { + let conn = connection.clone(); + let cb = callback.clone(); + let dg_cancel = acceptor_cancel.clone(); + tokio::spawn( + async move { + acceptor_loop( + dg_cancel, + "Read datagram", + || conn.conn.read_datagram(), + |datagram| { + let conn = conn.clone(); + let cb = cb.clone(); + async move { + if conn.uuid.load().is_some() { + tokio::spawn( + spawn_logged("Datagram", handle_datagram(conn, datagram, cb)) + .instrument(tracing::debug_span!("datagram")), + ); + } else if let Err(e) = handle_datagram(conn, datagram, cb).await { + error!("Datagram error: {e:?}"); + } + } + }, + ) + .await; + } + .in_current_span(), + ); + } + + // Uni stream acceptor. + { + let conn = connection.clone(); + let cb = callback.clone(); + let uni_cancel = acceptor_cancel.clone(); + tokio::spawn( + async move { + acceptor_loop( + uni_cancel, + "Accept uni", + || conn.conn.accept_uni(), + |recv| { + let conn = conn.clone(); + let cb = cb.clone(); + async move { + tokio::spawn( + spawn_logged("Uni stream", handle_uni_stream(conn, recv, cb)) + .instrument(tracing::debug_span!("uni_stream")), + ); + } + }, + ) + .await; + } + .in_current_span(), + ); + } + + // Bi stream acceptor. + { + let conn = connection.clone(); + let cb = callback.clone(); + let bi_cancel = acceptor_cancel.clone(); + tokio::spawn( + async move { + acceptor_loop( + bi_cancel, + "Accept bi", + || conn.conn.accept_bi(), + |(send, recv)| { + let conn = conn.clone(); + let cb = cb.clone(); + async move { + tokio::spawn( + spawn_logged("Bi stream", handle_bi_stream(conn, send, recv, cb)) + .instrument(tracing::debug_span!("bi_stream")), + ); + } + }, + ) + .await; + } + .in_current_span(), + ); + } + + // Exit on either server shutdown or peer disconnect. + tokio::select! { + _ = cancel.cancelled() => { + connection.conn.close(0, b"server shutdown"); + info!("Connection from {} closed by server shutdown", remote_addr); + } + _ = connection.conn.closed() => { + info!("Connection from {} closed", remote_addr); + } + } + acceptor_cancel.cancel(); +} + +async fn handle_uni_stream( + ctx: Arc>, + mut recv: C::RecvStream, + callback: CB, +) -> eyre::Result<()> { + let mut header_buf = [0u8; 2]; + recv.read_exact(&mut header_buf) + .await + .map_err(|e| eyre::eyre!("Failed to read uni stream header: {}", e))?; + let header = crate::proto::decode_header(&mut &header_buf[..], "uni stream")?; + + match header.command { + CmdType::Auth => { + let mut body = [0u8; 16 + 32]; + recv.read_exact(&mut body) + .await + .map_err(|e| eyre::eyre!("Failed to read auth body: {}", e))?; + let cmd = crate::proto::decode_command(CmdType::Auth, &mut &body[..], "uni stream")?; + if let Command::Auth { uuid, token } = cmd { + handle_auth(&ctx, uuid, token).await?; + } + } + cmd_type => { + if !ensure_authed(&ctx).await { + warn!( + command = ?cmd_type, + "Uni stream rejected: not authenticated within {:?}", + ctx.auth_timeout + ); + return Ok(()); + } + + match cmd_type { + CmdType::Packet => { + let mut cmd_body = [0u8; 8]; + recv.read_exact(&mut cmd_body) + .await + .map_err(|e| eyre::eyre!("Failed to read packet command: {}", e))?; + let cmd = crate::proto::decode_command(CmdType::Packet, &mut &cmd_body[..], "uni stream")?; + let Command::Packet { + assoc_id, + pkt_id, + frag_total, + frag_id, + size, + } = cmd + else { + unreachable!("decode_command(Packet, ..) must return Command::Packet"); + }; + + // Read address (capped at ~258 bytes). + let addr = read_address_exact(&mut recv) + .await + .wrap_err("Failed to read uni stream packet address")?; + + // Payload bounded by u16 size (≤ 65535). + let mut payload = vec![0u8; size as usize]; + if size > 0 { + recv.read_exact(&mut payload) + .await + .map_err(|e| eyre::eyre!("Failed to read packet payload: {}", e))?; + } + let payload = bytes::Bytes::from(payload); + + let target_addr = match crate::proto::address_to_target(addr) { + Ok(t) => t, + Err(_) => wind_core::types::TargetAddr::IPv4(std::net::Ipv4Addr::UNSPECIFIED, 0), + }; + handle_udp_packet(&ctx, assoc_id, pkt_id, frag_total, frag_id, target_addr, payload, &callback).await?; + } + CmdType::Dissociate => { + let mut body = [0u8; 2]; + recv.read_exact(&mut body) + .await + .map_err(|e| eyre::eyre!("Failed to read dissociate body: {}", e))?; + let cmd = crate::proto::decode_command(CmdType::Dissociate, &mut &body[..], "uni stream")?; + if let Command::Dissociate { assoc_id } = cmd { + handle_dissociate(&ctx, assoc_id).await?; + } + } + CmdType::Heartbeat => { + tracing::trace!("Received heartbeat from {:?}", ctx.uuid.load()); + } + other => { + warn!("Unexpected command on uni stream: {:?}", other); + } + } + } + } + + Ok(()) +} + +async fn handle_bi_stream( + connection: Arc>, + send: C::SendStream, + mut recv: C::RecvStream, + callback: CB, +) -> eyre::Result<()> { + if !ensure_authed(&connection).await { + warn!("Bi stream rejected: not authenticated within {:?}", connection.auth_timeout); + return Ok(()); + } + + let mut header_buf = [0u8; 2]; + recv.read_exact(&mut header_buf) + .await + .map_err(|e| eyre::eyre!("Failed to read header: {}", e))?; + let mut buf = &header_buf[..]; + + let header = crate::proto::decode_header(&mut buf, "bi stream")?; + + match header.command { + CmdType::Connect => { + let _cmd = crate::proto::decode_command(CmdType::Connect, &mut [].as_ref(), "bi stream")?; + + let addr = read_address_exact(&mut recv) + .await + .wrap_err("Failed to read connect address")?; + + let target_addr = crate::proto::address_to_target(addr)?; + + info!(target = %target_addr, "TCP connect"); + + // Join the recv/send halves into one duplex stream for the relay. + let stream = tokio::io::join(recv, send); + + callback.handle_tcpstream(target_addr, stream).await?; + } + _ => { + warn!("Unexpected command on bi stream: {:?}", header.command); + } + } + + Ok(()) +} + +async fn handle_datagram( + connection: Arc>, + data: bytes::Bytes, + callback: CB, +) -> eyre::Result<()> { + if !ensure_authed(&connection).await { + warn!("Datagram rejected: not authenticated within {:?}", connection.auth_timeout); + return Ok(()); + } + + let mut buf = data; + + let header = crate::proto::decode_header(&mut buf, "datagram")?; + + match header.command { + CmdType::Packet => { + let cmd = crate::proto::decode_command(CmdType::Packet, &mut buf, "datagram")?; + + if let Command::Packet { + assoc_id, + pkt_id, + frag_total, + frag_id, + size, + } = cmd + { + let addr = crate::proto::decode_address(&mut buf, "datagram packet")?; + if buf.len() < size as usize { + return Err(eyre::eyre!("datagram payload truncated: need {}, have {}", size, buf.len())); + } + let payload = buf.split_to(size as usize); + + let target_addr = match crate::proto::address_to_target(addr) { + Ok(t) => t, + Err(_) => wind_core::types::TargetAddr::IPv4(std::net::Ipv4Addr::UNSPECIFIED, 0), + }; + + tracing::debug!( + assoc_id, + pkt_id, + frag = format_args!("{}/{}", frag_id, frag_total), + target = %target_addr, + payload_len = payload.len(), + "UDP datagram received", + ); + + handle_udp_packet( + &connection, + assoc_id, + pkt_id, + frag_total, + frag_id, + target_addr, + payload, + &callback, + ) + .await?; + } + } + CmdType::Heartbeat => { + tracing::trace!("UDP heartbeat received"); + } + other => { + tracing::debug!(command = ?other, "unexpected datagram command"); + } + } + + Ok(()) +} + +async fn handle_auth(connection: &InboundCtx, uuid: Uuid, token: [u8; 32]) -> eyre::Result<()> { + // Look the user up, but never short-circuit on an unknown UUID — that would + // give an attacker both a timing oracle (skipped keying-material export) and + // an error-message oracle that reveals whether a UUID exists. Always run the + // export against either the real password or a fixed dummy and a constant-time + // comparison; both failure paths return the same generic error. + const DUMMY_PASSWORD: &[u8] = b"\x00\x00\x00\x00\x00\x00\x00\x00"; + let (password_bytes, user_known) = match connection.users.get(&uuid) { + Some(pw) => (pw.as_bytes(), true), + None => (DUMMY_PASSWORD, false), + }; + + let mut expected_token = [0u8; 32]; + let export_ok = connection + .conn + .export_keying_material(&mut expected_token, uuid.as_bytes(), password_bytes) + .await + .is_ok(); + + // Constant-time comparison: never short-circuit on first differing byte. + let mut diff: u8 = 0; + for (a, b) in token.iter().zip(expected_token.iter()) { + diff |= a ^ b; + } + let token_ok = diff == 0; + + if !(user_known && export_ok && token_ok) { + // Single generic error for "unknown user", "bad token", and "export + // failed" — do not leak which one triggered. + return Err(eyre::eyre!("Invalid authentication")); + } + + connection.uuid.store(Some(Arc::new(uuid))); + connection.auth_notify.notify_waiters(); + info!(uuid = %uuid, "authenticated"); + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_udp_packet( + ctx: &Arc>, + assoc_id: u16, + pkt_id: u16, + frag_total: u8, + frag_id: u8, + target_addr: wind_core::types::TargetAddr, + payload: bytes::Bytes, + callback: &CB, +) -> eyre::Result<()> { + if frag_total == 0 { + tracing::debug!(assoc_id, pkt_id, "dropping packet with frag_total=0"); + return Ok(()); + } + + let tuic_stream = get_or_create_session(ctx, assoc_id, callback).await?; + + if frag_total == 1 { + tracing::trace!(assoc_id, pkt_id, target = %target_addr, len = payload.len(), "UDP packet → outbound"); + tuic_stream + .receive_packet(UdpPacket { + source: None, + target: target_addr, + payload, + }) + .await?; + } else if let Some(complete) = tuic_stream + .process_fragment(assoc_id, pkt_id, frag_total, frag_id, payload, None, target_addr) + .await + { + tracing::debug!(assoc_id, pkt_id, frag_total, target = %complete.target, len = complete.payload.len(), "UDP packet reassembled → outbound"); + tuic_stream.receive_packet(complete).await?; + } + + Ok(()) +} + +/// Get an existing UDP session for `assoc_id` or create a new one. +async fn get_or_create_session( + ctx: &Arc>, + assoc_id: u16, + callback: &CB, +) -> eyre::Result>> { + if let Some(session) = ctx.udp_sessions.get(&assoc_id).await { + return Ok(session.tuic_stream.clone()); + } + + let cb = callback.clone(); + let conn = ctx.conn.clone(); + let session_cancel = ctx.udp_root_cancel.child_token(); + let session = ctx + .udp_sessions + .entry(assoc_id) + .or_insert_with(async { + info!("Creating new UDP session for assoc_id {}", assoc_id); + + let (reassembled_tx, reassembled_rx) = crossfire::mpmc::bounded_async::(128); + + let (to_outbound_tx, to_outbound_rx) = mpsc::channel::(100); + let (from_outbound_tx, mut from_outbound_rx) = mpsc::channel::(100); + + let tuic_stream = Arc::new(UdpStream::new(conn, assoc_id, reassembled_tx)); + + let outbound_stream = CoreUdpStream { + tx: from_outbound_tx, + rx: to_outbound_rx, + }; + + // Bridge reassembled packets -> outbound with backpressure. + let cancel_a = session_cancel.clone(); + tokio::spawn( + async move { + loop { + tokio::select! { + biased; + _ = cancel_a.cancelled() => break, + res = reassembled_rx.recv() => { + let packet = match res { + Ok(p) => p, + Err(_) => break, + }; + tokio::select! { + _ = cancel_a.cancelled() => break, + send_res = tokio::time::timeout(Duration::from_secs(5), to_outbound_tx.send(packet)) => { + match send_res { + Ok(Ok(())) => {} + Ok(Err(mpsc::error::SendError(_))) => break, + Err(_) => { + warn!("UDP outbound queue full (assoc {}), dropping packet after 5s", assoc_id); + } + } + } + } + } + } + } + } + .in_current_span(), + ); + + { + let response_stream = tuic_stream.clone(); + let cancel_b = session_cancel.clone(); + tokio::spawn( + async move { + loop { + tokio::select! { + biased; + _ = cancel_b.cancelled() => break, + maybe_packet = from_outbound_rx.recv() => { + let Some(packet) = maybe_packet else { break }; + if let Err(e) = response_stream.send_packet(packet).await { + warn!("Failed to send UDP response (assoc {}): {}", assoc_id, e); + break; + } + } + } + } + } + .in_current_span(), + ); + } + + { + let cancel_c = session_cancel.clone(); + tokio::spawn( + async move { + // `handle_udpstream` typically runs forever; race the + // session-cancel token so it exits with the rest of the + // session instead of holding the callback's resources + // hostage after eviction/dissociate. + tokio::select! { + _ = cancel_c.cancelled() => {} + res = cb.handle_udpstream(outbound_stream) => { + if let Err(e) = res { + error!("UDP stream handler error (assoc {}): {}", assoc_id, e); + } + } + } + } + .in_current_span(), + ); + } + + UdpSession { + tuic_stream, + cancel: session_cancel, + } + }) + .await; + + Ok(session.into_value().tuic_stream.clone()) +} + +async fn read_address_exact(recv: &mut R) -> eyre::Result { + let mut type_byte = [0u8; 1]; + recv.read_exact(&mut type_byte) + .await + .map_err(|e| eyre::eyre!("Failed to read address type: {}", e))?; + + match type_byte[0] { + 0xFF => Ok(crate::proto::Address::None), + 0x01 => { + let mut ip_bytes = [0u8; 4]; + recv.read_exact(&mut ip_bytes) + .await + .map_err(|e| eyre::eyre!("Failed to read IPv4 address: {}", e))?; + let mut port_buf = [0u8; 2]; + recv.read_exact(&mut port_buf) + .await + .map_err(|e| eyre::eyre!("Failed to read IPv4 port: {}", e))?; + Ok(crate::proto::Address::IPv4( + std::net::Ipv4Addr::from(ip_bytes), + u16::from_be_bytes(port_buf), + )) + } + 0x02 => { + let mut ip_bytes = [0u8; 16]; + recv.read_exact(&mut ip_bytes) + .await + .map_err(|e| eyre::eyre!("Failed to read IPv6 address: {}", e))?; + let mut port_buf = [0u8; 2]; + recv.read_exact(&mut port_buf) + .await + .map_err(|e| eyre::eyre!("Failed to read IPv6 port: {}", e))?; + Ok(crate::proto::Address::IPv6( + std::net::Ipv6Addr::from(ip_bytes), + u16::from_be_bytes(port_buf), + )) + } + 0x00 => { + // AddressType::Domain — 1-byte length + bytes + 2-byte port + let mut len_byte = [0u8; 1]; + recv.read_exact(&mut len_byte) + .await + .map_err(|e| eyre::eyre!("Failed to read domain length: {}", e))?; + let domain_len = len_byte[0] as usize; + + let mut domain = vec![0u8; domain_len]; + recv.read_exact(&mut domain) + .await + .map_err(|e| eyre::eyre!("Failed to read domain address: {}", e))?; + + let mut port_buf = [0u8; 2]; + recv.read_exact(&mut port_buf) + .await + .map_err(|e| eyre::eyre!("Failed to read domain port: {}", e))?; + let port = u16::from_be_bytes(port_buf); + + let domain_str = String::from_utf8(domain).map_err(|_| eyre::eyre!("Invalid UTF-8 domain address"))?; + Ok(crate::proto::Address::Domain(domain_str, port)) + } + t => Err(eyre::eyre!("Unknown address type byte 0x{:02x}", t)), + } +} + +/// Handle UDP dissociate +async fn handle_dissociate(connection: &InboundCtx, assoc_id: u16) -> eyre::Result<()> { + connection.udp_sessions.remove(&assoc_id).await; + info!("Dissociated UDP session {}", assoc_id); + Ok(()) +} diff --git a/crates/wind-tuiche/Cargo.toml b/crates/wind-tuiche/Cargo.toml deleted file mode 100644 index fe9ad27..0000000 --- a/crates/wind-tuiche/Cargo.toml +++ /dev/null @@ -1,50 +0,0 @@ -[package] -name = "wind-tuiche" -version.workspace = true -repository.workspace = true -edition.workspace = true -description = "TUIC over QUIC using the tokio-quiche backend" -license = "MIT OR Apache-2.0" - -[features] -default = ["server", "client"] -# The server both decodes client requests and encodes UDP response datagrams, -# so it needs both codec directions; the client (outbound) needs encode. -server = ["tuic-core/decode", "tuic-core/encode"] -client = ["tuic-core/encode"] - -[dependencies] -wind-core = { version = "0.1.1", path = "../wind-core", default-features = false } - -# Shared backend-agnostic TUIC protocol codecs + fragment state machine. -tuic-core = { version = "0.1.1", path = "../tuic-core", default-features = false } - -# Async -tokio = { version = "1", default-features = false, features = ["net", "time", "rt", "sync", "io-util", "macros"] } -tokio-util = { version = "0.7", default-features = false, features = ["codec"] } -futures-util = { version = "0.3", default-features = false, features = ["std"] } - -# Utilities -arc-swap = "1" -bytes = "1" -uuid = { version = "1", features = ["v4"] } -eyre = "0.6" -tracing = "0.1" -thiserror = "2" - -# QUIC backend. tokio-quiche 0.19 tracks quiche ^0.29 and serde_with ^3.20. -# -# tokio-quiche upstream only compiles on 64-bit (its GSO path transmutes -# `u128` -> `Instant`); the workspace carries a small patch -# (`patches/tokio-quiche`, wired via the root `[patch.crates-io]`) that makes it -# build on 32-bit too, so this dependency is no longer target-gated. -tokio-quiche = "0.19" -# Direct access to the BoringSSL `SSL` object behind the quiche connection for -# the RFC 5705 keying-material exporter used by TUIC authentication. -boring = { version = "4", default-features = false } -boring-sys = { version = "4", default-features = false } -# Provides `ForeignTypeRef::as_ptr` for the FFI exporter call. -foreign-types-shared = "0.3" - -[dev-dependencies] -tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] } diff --git a/crates/wind-tuiche/README.md b/crates/wind-tuiche/README.md deleted file mode 100644 index 31078cc..0000000 --- a/crates/wind-tuiche/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# wind-tuiche - -TUIC protocol implementation built on the [`tokio-quiche`](https://docs.rs/tokio-quiche) backend. - -## Overview - -`wind-tuiche` is a TUIC (TCP/UDP over QUIC) implementation that drives the -underlying QUIC stack with Cloudflare's [`tokio-quiche`](https://github.com/cloudflare/quiche/tree/master/tokio-quiche) -library (an async wrapper around `quiche`). It is the counterpart to -`wind-tuic`, which uses the [quinn](https://github.com/quinn-rs/quinn) stack -instead. - -## Status - -The **server** path implements the TUIC protocol on top of `tokio-quiche`'s -`ApplicationOverQuic` worker (see `driver.rs`): - -- **Authentication** over a unidirectional stream. -- **TCP `CONNECT`** relay over bidirectional streams, bridged to the `wind-core` - `handle_tcpstream` callback via a channel-backed duplex stream (`stream.rs`). -- **UDP** native relay over QUIC DATAGRAMs (RFC 9221) and `Packet` commands on - unidirectional streams, using the shared `tuic-core` - `FragmentReassemblyBuffer` for reassembly and re-encoding responses as - datagrams (with fragmentation). -- **Heartbeat** and **Dissociate** handling. - -The **client** (outbound) path is still a configuration-only placeholder. - -### Authentication - -TUIC derives its auth token from the TLS keying-material exporter (RFC 5705, -label = UUID bytes, context = password). This backend recomputes it from the -live BoringSSL session and compares in constant time, exactly like the quinn -backend. quiche exposes the underlying `boring::ssl::SslRef` via -`impl AsMut for Connection` when built with the `boringssl-boring-crate` -feature (which `tokio-quiche` enables), so the token is fully verified. - -## Features - -- TCP-over-QUIC (`CONNECT`) relay -- UDP-over-QUIC relay with RFC 9221 DATAGRAMs + fragment reassembly -- BoringSSL via `tokio-quiche` / `quiche` -- Async/await architecture with tokio -- Server and client builder APIs - -## Usage - -### Server - -```rust -use wind_tuiche::TuicheInboundBuilder; -use uuid::Uuid; - -let server = TuicheInboundBuilder::new() - .listen_addr("0.0.0.0:443".parse()?) - .certificate_path("cert.pem") - .private_key_path("key.pem") - .user(uuid, "password".to_string()) - .build() - .await?; -``` - -### Client - -```rust -use wind_tuiche::TuicheOutboundBuilder; - -let client = TuicheOutboundBuilder::new() - .server_addr("server.example.com:443".parse()?) - .server_name("server.example.com".to_string()) - .uuid(uuid) - .password("password".to_string()) - .build()?; -``` - -## Building - -```bash -cargo build --features "server,client" -``` - -## Testing - -```bash -cargo test -``` - -## License - -MIT OR Apache-2.0 diff --git a/crates/wind-tuiche/src/driver.rs b/crates/wind-tuiche/src/driver.rs deleted file mode 100644 index 56bcca2..0000000 --- a/crates/wind-tuiche/src/driver.rs +++ /dev/null @@ -1,993 +0,0 @@ -//! [`ApplicationOverQuic`] implementation that drives the TUIC protocol on top -//! of a single `tokio-quiche` connection. -//! -//! `tokio-quiche` runs one worker task per connection and calls our methods -//! synchronously with `&mut quiche::Connection`. We translate between that -//! sans-IO model and the async `wind-core` relay callbacks using per-stream -//! channels: -//! -//! * **TCP CONNECT** (client bidi streams) → a [`QuicheStream`] handed to -//! [`InboundCallback::handle_tcpstream`]. The worker pumps client→target -//! bytes into the stream and drains target→client bytes back out. -//! * **UDP** (datagrams, native relay mode; also `Packet` on uni streams) → a -//! per-association reassembly task (using the shared -//! [`tuic_core::udp::FragmentReassemblyBuffer`]) feeding -//! [`InboundCallback::handle_udpstream`]; responses are re-encoded as TUIC -//! datagrams. -//! * **Auth / Heartbeat / Dissociate** are handled inline. -//! -//! ## Authentication -//! -//! TUIC authenticates with a token derived from the TLS keying-material -//! exporter (RFC 5705, label = UUID bytes, context = password). We recompute it -//! from the live BoringSSL session and compare in constant time, exactly like -//! the quinn backend. quiche exposes the underlying `boring::ssl::SslRef` via -//! `impl AsMut for Connection` when built with `boringssl-boring-crate` -//! (which tokio-quiche enables); see [`export_keying_material`]. - -use std::collections::{HashMap, VecDeque}; - -use bytes::{Buf, Bytes, BytesMut}; -use futures_util::{StreamExt, stream::FuturesUnordered}; -use tokio::sync::mpsc::{self, error::TrySendError}; -use tokio_quiche::{ - ApplicationOverQuic, QuicResult, - quic::{HandshakeInfo, QuicheConnection}, - quiche::{self, Shutdown}, -}; -use tokio_util::codec::{Decoder, Encoder}; -use tracing::{Instrument as _, Span, debug, trace, warn}; -use tuic_core::{ - proto::{ - Address, AddressCodec, CmdCodec, CmdType, Command, Header, HeaderCodec, address_to_target, decode_address, - decode_command, decode_header, - }, - udp::{FragmentInfo, FragmentReassemblyBuffer}, -}; -use uuid::Uuid; -use wind_core::{ - InboundCallback, - types::TargetAddr, - udp::{UdpPacket, UdpStream as CoreUdpStream}, -}; - -use crate::stream::{QuicheStream, TcpBack, WaitTcpBack}; - -type BoxErr = Box; - -const READ_BUF_SIZE: usize = 64 * 1024; -const TCP_CHANNEL_CAP: usize = 64; -const UDP_OUTBOUND_CAP: usize = 256; -const UDP_RESP_CAP: usize = 256; -const UDP_FRAG_CAP: usize = 256; -/// Per-stream cap on buffered client→target bytes before we stop draining the -/// QUIC stream and let flow control backpressure the client. -const MAX_TCP_BUFFER: usize = 256 * 1024; -/// Cap on the unparsed CONNECT header+address prefix; anything larger is junk. -const MAX_HEADER_PREFIX: usize = 4 * 1024; -/// Cap on a single uni stream's buffered bytes (one Auth/Packet/Dissociate -/// cmd). -const MAX_UNI_BUFFER: usize = 70 * 1024; -const MAX_FRAGMENTS: u8 = 255; -const DEFAULT_MAX_DATAGRAM: usize = 1200; - -fn boxed(e: E) -> BoxErr { - Box::new(e) -} - -fn is_done(e: &quiche::Error) -> bool { - matches!(e, quiche::Error::Done) -} - -/// Recompute a TUIC auth token from the live BoringSSL session via the RFC 5705 -/// keying-material exporter, returning the 32-byte token on success. -/// -/// quiche exposes the underlying `boring::ssl::SslRef` through -/// `impl AsMut for Connection` when built with `boringssl-boring-crate` -/// (enabled by tokio-quiche). We call the BoringSSL FFI directly rather than -/// the safe `SslRef::export_keying_material`, because the safe wrapper takes -/// the label as `&str` while TUIC uses the raw (non-UTF-8) UUID bytes as the -/// label. -fn export_keying_material(qconn: &mut QuicheConnection, label: &[u8], context: &[u8]) -> Option<[u8; 32]> { - use foreign_types_shared::ForeignTypeRef as _; - - let ssl: &mut boring::ssl::SslRef = qconn.as_mut(); - let mut out = [0u8; 32]; - // SAFETY: `ssl.as_ptr()` yields a valid `SSL*` for the duration of the - // borrow; `out`/`label`/`context` are passed as (ptr, len) of valid slices - // and BoringSSL does not retain any of them past the call. - let rc = unsafe { - boring_sys::SSL_export_keying_material( - ssl.as_ptr(), - out.as_mut_ptr(), - out.len(), - label.as_ptr() as *const core::ffi::c_char, - label.len(), - context.as_ptr(), - context.len(), - 1, // use_context = true - ) - }; - (rc == 1).then_some(out) -} - -/// Per bidirectional (TCP CONNECT) stream state. -struct TcpState { - /// `false` until the CONNECT header+address is parsed and the relay starts. - started: bool, - /// Accumulates the CONNECT header+address prefix before the relay starts; - /// after parsing, holds the leftover first payload until moved to the - /// proxy. - header_buf: BytesMut, - /// Worker → proxy (client→target). `None` once the client's FIN is observed - /// and the buffer is drained (signals EOF to the relay). - to_proxy: Option>, - /// Client→target bytes not yet accepted by `to_proxy` (bounded channel - /// full). - to_proxy_buf: VecDeque, - /// Target→client bytes pending `stream_send`. - queued: BytesMut, - client_fin: bool, - proxy_done: bool, - fin_sent: bool, -} - -impl TcpState { - fn new() -> Self { - Self { - started: false, - header_buf: BytesMut::new(), - to_proxy: None, - to_proxy_buf: VecDeque::new(), - queued: BytesMut::new(), - client_fin: false, - proxy_done: false, - fin_sent: false, - } - } - - fn buffered_to_proxy(&self) -> usize { - self.to_proxy_buf.iter().map(|b| b.len()).sum() - } -} - -/// Per UDP association state held by the worker. -struct UdpSession { - /// Worker → reassembly task (raw decoded fragments). - frag_tx: mpsc::Sender, - /// Monotonic packet-id counter for server→client response datagrams. - next_pkt_id: u16, -} - -/// A decoded UDP fragment handed to the per-association reassembly task. -struct FragmentInput { - assoc_id: u16, - pkt_id: u16, - frag_total: u8, - frag_id: u8, - target: TargetAddr, - payload: Bytes, -} - -enum ParseOutcome { - NeedMore, - Bad, - Connect(TargetAddr), -} - -pub struct TuicheDriver { - callback: C, - users: std::sync::Arc>>, - /// Per-connection tracing span (`conn{peer=…}`). Entered in the - /// `ApplicationOverQuic` callbacks so every inline log line is tagged with - /// the peer, and used as the parent of the per-stream/per-association spans - /// on the relay tasks we spawn. - span: Span, - established: bool, - authed: bool, - buffer: Vec, - tcp: HashMap, - uni: HashMap, - waiters: FuturesUnordered, - out_datagrams: VecDeque, - udp: HashMap, - udp_resp_tx: mpsc::Sender<(u16, UdpPacket)>, - udp_resp_rx: mpsc::Receiver<(u16, UdpPacket)>, -} - -impl TuicheDriver { - pub fn new(callback: C, users: std::sync::Arc>>, span: Span) -> Self { - let (udp_resp_tx, udp_resp_rx) = mpsc::channel(UDP_RESP_CAP); - Self { - callback, - users, - span, - established: false, - authed: false, - buffer: vec![0u8; READ_BUF_SIZE], - tcp: HashMap::new(), - uni: HashMap::new(), - waiters: FuturesUnordered::new(), - out_datagrams: VecDeque::new(), - udp: HashMap::new(), - udp_resp_tx, - udp_resp_rx, - } - } - - // ----- reads --------------------------------------------------------- - - fn drain_datagrams(&mut self, qconn: &mut QuicheConnection) { - loop { - match qconn.dgram_recv(&mut self.buffer) { - Ok(n) => { - let data = Bytes::copy_from_slice(&self.buffer[..n]); - self.handle_datagram(data); - } - Err(quiche::Error::Done) => break, - Err(e) => { - trace!("dgram_recv error: {e}"); - break; - } - } - } - } - - fn handle_datagram(&mut self, data: Bytes) { - let mut buf = data; - let header = match decode_header(&mut buf, "datagram") { - Ok(h) => h, - Err(e) => { - debug!("bad datagram header: {e}"); - return; - } - }; - match header.command { - CmdType::Heartbeat => {} - CmdType::Packet => { - if !self.authed { - return; - } - if let Some(input) = decode_packet(&mut buf) { - self.dispatch_fragment(input); - } - } - other => debug!("unexpected datagram command {other:?}"), - } - } - - fn read_tcp(&mut self, qconn: &mut QuicheConnection, sid: u64) -> QuicResult<()> { - // Backpressure / admission. - match self.tcp.get(&sid) { - Some(st) if st.buffered_to_proxy() > MAX_TCP_BUFFER => return Ok(()), - Some(_) => {} - None => { - if !self.authed { - let _ = qconn.stream_shutdown(sid, Shutdown::Read, 0); - let _ = qconn.stream_shutdown(sid, Shutdown::Write, 0); - return Ok(()); - } - self.tcp.insert(sid, TcpState::new()); - } - } - - // Drain currently-available stream data. - let mut incoming = BytesMut::new(); - let mut fin = false; - loop { - match qconn.stream_recv(sid, &mut self.buffer) { - Ok((n, f)) => { - if n > 0 { - incoming.extend_from_slice(&self.buffer[..n]); - } - fin |= f; - if f || incoming.len() > MAX_TCP_BUFFER { - break; - } - } - Err(e) if is_done(&e) => break, - Err(e) => { - debug!(stream = sid, "stream_recv error: {e}"); - self.tcp.remove(&sid); - return Ok(()); - } - } - } - - let started = self.tcp.get(&sid).map(|s| s.started).unwrap_or(false); - if started { - if let Some(st) = self.tcp.get_mut(&sid) { - if !incoming.is_empty() { - st.to_proxy_buf.push_back(incoming.freeze()); - } - if fin { - st.client_fin = true; - } - } - self.flush_to_proxy(sid); - } else { - // Still parsing the CONNECT prefix. - let outcome = { - let st = self.tcp.get_mut(&sid).expect("tcp state present"); - st.header_buf.extend_from_slice(&incoming); - if fin { - st.client_fin = true; - } - try_parse_connect(&mut st.header_buf)? - }; - match outcome { - ParseOutcome::NeedMore => { - let st = self.tcp.get(&sid).expect("tcp state present"); - if st.header_buf.len() > MAX_HEADER_PREFIX || st.client_fin { - let _ = qconn.stream_shutdown(sid, Shutdown::Read, 0); - let _ = qconn.stream_shutdown(sid, Shutdown::Write, 0); - self.tcp.remove(&sid); - } - } - ParseOutcome::Bad => { - let _ = qconn.stream_shutdown(sid, Shutdown::Read, 0); - let _ = qconn.stream_shutdown(sid, Shutdown::Write, 0); - self.tcp.remove(&sid); - } - ParseOutcome::Connect(target) => { - debug!(stream = sid, target = %target, "tuiche TCP connect"); - self.begin_tcp_relay(sid, target); - } - } - } - Ok(()) - } - - fn begin_tcp_relay(&mut self, sid: u64, target: TargetAddr) { - let (to_proxy_tx, to_proxy_rx) = mpsc::channel::(TCP_CHANNEL_CAP); - let (from_proxy_tx, from_proxy_rx) = mpsc::channel::(TCP_CHANNEL_CAP); - let qstream = QuicheStream::new(to_proxy_rx, from_proxy_tx); - - let cb = self.callback.clone(); - let span = tracing::debug_span!(parent: &self.span, "tcp", stream = sid, target = %target); - tokio::spawn( - async move { - if let Err(e) = cb.handle_tcpstream(target, qstream).await { - debug!("tuiche TCP relay ended: {e}"); - } - } - .instrument(span), - ); - - self.waiters.push(WaitTcpBack::new(sid, from_proxy_rx)); - - if let Some(st) = self.tcp.get_mut(&sid) { - st.started = true; - let leftover = std::mem::take(&mut st.header_buf).freeze(); - if !leftover.is_empty() { - st.to_proxy_buf.push_back(leftover); - } - st.to_proxy = Some(to_proxy_tx); - } - self.flush_to_proxy(sid); - } - - fn read_uni(&mut self, qconn: &mut QuicheConnection, sid: u64) -> QuicResult<()> { - let mut fin = false; - let buf = self.uni.entry(sid).or_default(); - loop { - match qconn.stream_recv(sid, &mut self.buffer) { - Ok((n, f)) => { - if n > 0 { - buf.extend_from_slice(&self.buffer[..n]); - } - fin |= f; - if f || buf.len() > MAX_UNI_BUFFER { - break; - } - } - Err(e) if is_done(&e) => break, - Err(e) => { - debug!(stream = sid, "uni stream_recv error: {e}"); - self.uni.remove(&sid); - return Ok(()); - } - } - } - - if !fin { - if self.uni.get(&sid).map(|b| b.len()).unwrap_or(0) > MAX_UNI_BUFFER { - self.uni.remove(&sid); - } - return Ok(()); - } - - let data = self.uni.remove(&sid).unwrap_or_default(); - self.handle_uni_command(qconn, data.freeze()); - Ok(()) - } - - fn handle_uni_command(&mut self, qconn: &mut QuicheConnection, data: Bytes) { - let mut buf = data; - let header = match decode_header(&mut buf, "uni") { - Ok(h) => h, - Err(e) => { - debug!("bad uni header: {e}"); - return; - } - }; - match header.command { - CmdType::Auth => match decode_command(CmdType::Auth, &mut buf, "uni auth") { - Ok(Command::Auth { uuid, token }) => self.handle_auth(qconn, uuid, token), - _ => debug!("malformed auth command"), - }, - CmdType::Heartbeat => {} - CmdType::Dissociate => { - if !self.authed { - return; - } - if let Ok(Command::Dissociate { assoc_id }) = decode_command(CmdType::Dissociate, &mut buf, "uni dissociate") { - self.udp.remove(&assoc_id); - debug!(assoc_id, "dissociated UDP session"); - } - } - CmdType::Packet => { - if !self.authed { - return; - } - if let Some(input) = decode_packet(&mut buf) { - self.dispatch_fragment(input); - } - } - other => debug!("unexpected uni command {other:?}"), - } - } - - fn handle_auth(&mut self, qconn: &mut QuicheConnection, uuid: Uuid, token: [u8; 32]) { - // TUIC's auth token is the RFC 5705 TLS keying-material exporter output - // with label = the UUID bytes and context = the user's password. We - // recompute it from the live BoringSSL session and compare in constant - // time. To avoid a timing/Err oracle that reveals whether a UUID exists, - // always run the exporter (against the real or a fixed dummy password) - // and a constant-time comparison; every failure path returns the same - // generic rejection. Mirrors `wind-tuic`'s `handle_auth`. - const DUMMY_PASSWORD: &[u8] = b"\x00\x00\x00\x00\x00\x00\x00\x00"; - let (password, user_known): (&[u8], bool) = match self.users.get(&uuid) { - Some(pw) => (pw.as_slice(), true), - None => (DUMMY_PASSWORD, false), - }; - - let expected = export_keying_material(qconn, uuid.as_bytes(), password); - - let token_ok = match &expected { - Some(exp) => { - let mut diff = 0u8; - for (a, b) in token.iter().zip(exp.iter()) { - diff |= a ^ b; - } - diff == 0 - } - None => false, - }; - - if user_known && token_ok { - self.authed = true; - debug!(%uuid, "tuiche authenticated"); - } else { - warn!(%uuid, "tuiche auth rejected"); - } - } - - // ----- UDP ----------------------------------------------------------- - - fn dispatch_fragment(&mut self, input: FragmentInput) { - let assoc_id = input.assoc_id; - let frag_tx = self.udp_session(assoc_id); - if let Err(e) = frag_tx.try_send(input) { - match e { - TrySendError::Full(_) => debug!(assoc_id, "UDP reassembly queue full; dropping fragment"), - TrySendError::Closed(_) => { - self.udp.remove(&assoc_id); - } - } - } - } - - /// Get (or lazily create) the reassembly channel for an association. - fn udp_session(&mut self, assoc_id: u16) -> mpsc::Sender { - if let Some(s) = self.udp.get(&assoc_id) { - return s.frag_tx.clone(); - } - - let (frag_tx, frag_rx) = mpsc::channel::(UDP_FRAG_CAP); - let (to_outbound_tx, to_outbound_rx) = mpsc::channel::(UDP_OUTBOUND_CAP); - let (from_outbound_tx, from_outbound_rx) = mpsc::channel::(UDP_OUTBOUND_CAP); - - // All three per-association tasks share one `udp{assoc_id}` span parented - // to the connection span, so their logs group under the connection's peer. - let span = tracing::debug_span!(parent: &self.span, "udp", assoc_id); - - // Reassembly task: raw fragments -> complete packets -> outbound relay. - tokio::spawn(udp_reassembly_task(assoc_id, frag_rx, to_outbound_tx).instrument(span.clone())); - - // Relay task: hand the UdpStream to the wind-core callback. - let cb = self.callback.clone(); - let core_stream = CoreUdpStream { - tx: from_outbound_tx, - rx: to_outbound_rx, - }; - tokio::spawn( - async move { - if let Err(e) = cb.handle_udpstream(core_stream).await { - debug!(assoc_id, "tuiche UDP relay ended: {e}"); - } - } - .instrument(span.clone()), - ); - - // Forwarder: tag the callback's response packets with the association id - // and funnel them to the worker's single response channel. - let resp_tx = self.udp_resp_tx.clone(); - let mut from_outbound_rx = from_outbound_rx; - tokio::spawn( - async move { - while let Some(pkt) = from_outbound_rx.recv().await { - if resp_tx.send((assoc_id, pkt)).await.is_err() { - break; - } - } - } - .instrument(span), - ); - - self.udp.insert( - assoc_id, - UdpSession { - frag_tx: frag_tx.clone(), - next_pkt_id: 0, - }, - ); - frag_tx - } - - /// Encode a server→client UDP response into one or more TUIC datagrams. - fn enqueue_udp_response(&mut self, assoc_id: u16, packet: UdpPacket, max_datagram: usize) { - let pkt_id = match self.udp.get_mut(&assoc_id) { - Some(s) => { - let id = s.next_pkt_id; - s.next_pkt_id = s.next_pkt_id.wrapping_add(1); - id - } - None => 0, - }; - - let target = packet.target; - let payload = packet.payload; - let addr_size = address_size(&target); - let single_overhead = 2 + 8 + addr_size; - - if single_overhead + payload.len() <= max_datagram { - self.out_datagrams - .push_back(encode_packet(assoc_id, pkt_id, 1, 0, &target, &payload)); - return; - } - - // Fragment: first fragment carries the address, the rest use Address::None. - let first_max = max_datagram.saturating_sub(2 + 8 + addr_size); - let sub_max = max_datagram.saturating_sub(2 + 8 + 1); - if first_max == 0 || sub_max == 0 { - debug!(assoc_id, "max datagram too small for UDP response header; dropping"); - return; - } - let frag_count = 1 + (payload.len() - first_max).div_ceil(sub_max); - if frag_count > MAX_FRAGMENTS as usize { - debug!(assoc_id, "UDP response too large to fragment; dropping"); - return; - } - let frag_total = frag_count as u8; - - let mut offset = 0usize; - for frag_id in 0..frag_count { - let max = if frag_id == 0 { first_max } else { sub_max }; - let end = (offset + max).min(payload.len()); - let chunk = payload.slice(offset..end); - let dg = if frag_id == 0 { - encode_packet(assoc_id, pkt_id, frag_total, 0, &target, &chunk) - } else { - encode_packet_no_addr(assoc_id, pkt_id, frag_total, frag_id as u8, &chunk) - }; - self.out_datagrams.push_back(dg); - offset = end; - } - } - - // ----- writes / backchannel ----------------------------------------- - - fn handle_back(&mut self, back: TcpBack) { - let TcpBack { stream_id, data, rx } = back; - let Some(st) = self.tcp.get_mut(&stream_id) else { - return; - }; - match data { - Some(bytes) => { - st.queued.extend_from_slice(&bytes); - // Re-arm the back-channel waiter. - self.waiters.push(WaitTcpBack::new(stream_id, rx)); - } - None => { - st.proxy_done = true; - } - } - } - - fn flush_to_proxy(&mut self, sid: u64) { - let Some(st) = self.tcp.get_mut(&sid) else { return }; - let Some(tx) = st.to_proxy.clone() else { return }; - while st.to_proxy_buf.front().is_some() { - match tx.try_reserve() { - Ok(permit) => { - let chunk = st.to_proxy_buf.pop_front().unwrap(); - permit.send(chunk); - } - Err(TrySendError::Full(())) => break, - Err(TrySendError::Closed(())) => { - st.to_proxy_buf.clear(); - st.to_proxy = None; - return; - } - } - } - if st.client_fin && st.to_proxy_buf.is_empty() { - // Drop the sender so the relay observes EOF on the read half. - st.to_proxy = None; - } - } -} - -impl ApplicationOverQuic for TuicheDriver { - fn on_conn_established(&mut self, qconn: &mut QuicheConnection, _info: &HandshakeInfo) -> QuicResult<()> { - let span = self.span.clone(); - let _enter = span.enter(); - self.established = true; - debug!(trace = qconn.trace_id(), "tuiche connection established"); - Ok(()) - } - - fn should_act(&self) -> bool { - self.established - } - - fn buffer(&mut self) -> &mut [u8] { - &mut self.buffer - } - - async fn wait_for_data(&mut self, qconn: &mut QuicheConnection) -> QuicResult<()> { - enum Ev { - Tcp(TcpBack), - Udp((u16, UdpPacket)), - } - let ev = { - let waiters = &mut self.waiters; - let urx = &mut self.udp_resp_rx; - tokio::select! { - Some(b) = waiters.next() => Ev::Tcp(b), - Some(u) = urx.recv() => Ev::Udp(u), - _ = std::future::pending::<()>() => unreachable!(), - } - }; - // Enter the connection span only for the synchronous handling below; a - // span guard must never be held across the `.await` in the select above. - let span = self.span.clone(); - let _enter = span.enter(); - match ev { - Ev::Tcp(b) => self.handle_back(b), - Ev::Udp((assoc_id, packet)) => { - let max = qconn.dgram_max_writable_len().unwrap_or(DEFAULT_MAX_DATAGRAM); - self.enqueue_udp_response(assoc_id, packet, max); - } - } - Ok(()) - } - - fn process_reads(&mut self, qconn: &mut QuicheConnection) -> QuicResult<()> { - let span = self.span.clone(); - let _enter = span.enter(); - self.drain_datagrams(qconn); - - let ids: Vec = qconn.readable().collect(); - for sid in ids { - // Bit 0x2 clear => bidirectional (TCP CONNECT); set => unidirectional. - if sid & 0x2 == 0 { - self.read_tcp(qconn, sid)?; - } else { - self.read_uni(qconn, sid)?; - } - } - Ok(()) - } - - fn process_writes(&mut self, qconn: &mut QuicheConnection) -> QuicResult<()> { - let span = self.span.clone(); - let _enter = span.enter(); - // Flush queued response datagrams. - while let Some(dg) = self.out_datagrams.front() { - match qconn.dgram_send(dg.as_ref()) { - Ok(()) => { - self.out_datagrams.pop_front(); - } - Err(quiche::Error::Done) => break, - Err(e) => { - trace!("dgram_send error: {e}"); - self.out_datagrams.pop_front(); - } - } - } - - // Flush per-stream traffic. - let sids: Vec = self.tcp.keys().copied().collect(); - for sid in sids { - self.flush_to_proxy(sid); - - let mut remove = false; - if let Some(st) = self.tcp.get_mut(&sid) { - if !st.queued.is_empty() { - match qconn.stream_send(sid, st.queued.as_ref(), false) { - Ok(n) => { - st.queued.advance(n); - } - Err(quiche::Error::Done) => {} - Err(e) => { - debug!(stream = sid, "stream_send error: {e}"); - remove = true; - } - } - } - if st.queued.is_empty() && st.proxy_done && !st.fin_sent { - match qconn.stream_send(sid, b"", true) { - Ok(_) => st.fin_sent = true, - Err(quiche::Error::Done) => {} - Err(e) => { - debug!(stream = sid, "stream_send fin error: {e}"); - remove = true; - } - } - } - if st.fin_sent && st.to_proxy.is_none() && st.to_proxy_buf.is_empty() { - remove = true; - } - } - if remove { - self.tcp.remove(&sid); - } - } - Ok(()) - } -} - -// ----- free helpers ------------------------------------------------------ - -/// Per-association reassembly: raw fragments → complete `UdpPacket`s. -async fn udp_reassembly_task(assoc_id: u16, mut frag_rx: mpsc::Receiver, to_outbound: mpsc::Sender) { - let frag = FragmentReassemblyBuffer::new(); - while let Some(input) = frag_rx.recv().await { - let packet = if input.frag_total <= 1 { - Some(UdpPacket { - source: None, - target: input.target, - payload: input.payload, - }) - } else { - frag.add_fragment( - FragmentInfo { - assoc_id, - pkt_id: input.pkt_id, - frag_total: input.frag_total, - frag_id: input.frag_id, - source: None, - target: input.target, - }, - input.payload, - ) - .await - }; - - if let Some(packet) = packet - && to_outbound.send(packet).await.is_err() - { - break; - } - } -} - -/// Decode a `Packet` body (command + address + payload) into a -/// [`FragmentInput`]. The header has already been consumed by the caller. -fn decode_packet(buf: &mut Bytes) -> Option { - let cmd = decode_command(CmdType::Packet, buf, "packet").ok()?; - let Command::Packet { - assoc_id, - pkt_id, - frag_total, - frag_id, - size, - } = cmd - else { - return None; - }; - let addr = decode_address(buf, "packet addr").ok()?; - let target = address_to_target(addr).unwrap_or(TargetAddr::IPv4(std::net::Ipv4Addr::UNSPECIFIED, 0)); - if buf.remaining() < size as usize { - return None; - } - let payload = buf.copy_to_bytes(size as usize); - Some(FragmentInput { - assoc_id, - pkt_id, - frag_total, - frag_id, - target, - payload, - }) -} - -/// Try to parse a CONNECT header+address from the accumulated prefix. -/// -/// Parses on a clone and only commits (`*buf = rest`) on success, so an -/// incomplete prefix is preserved for the next read. On success `buf` is left -/// holding the leftover first payload bytes. -fn try_parse_connect(buf: &mut BytesMut) -> QuicResult { - let mut tmp = buf.clone(); - - let header = match HeaderCodec.decode(&mut tmp).map_err(boxed)? { - Some(h) => h, - None => return Ok(ParseOutcome::NeedMore), - }; - if header.command != CmdType::Connect { - return Ok(ParseOutcome::Bad); - } - // CONNECT carries no command body. - if CmdCodec(CmdType::Connect).decode(&mut tmp).map_err(boxed)?.is_none() { - return Ok(ParseOutcome::NeedMore); - } - let addr = match AddressCodec.decode(&mut tmp).map_err(boxed)? { - Some(a) => a, - None => return Ok(ParseOutcome::NeedMore), - }; - let Ok(target) = address_to_target(addr) else { - return Ok(ParseOutcome::Bad); - }; - - *buf = tmp; - Ok(ParseOutcome::Connect(target)) -} - -fn address_size(target: &TargetAddr) -> usize { - match target { - TargetAddr::IPv4(..) => 1 + 4 + 2, - TargetAddr::IPv6(..) => 1 + 16 + 2, - TargetAddr::Domain(d, _) => 1 + 1 + d.len() + 2, - } -} - -fn encode_packet(assoc_id: u16, pkt_id: u16, frag_total: u8, frag_id: u8, target: &TargetAddr, payload: &[u8]) -> Bytes { - let mut buf = BytesMut::with_capacity(2 + 8 + address_size(target) + payload.len()); - encode_packet_prefix(&mut buf, assoc_id, pkt_id, frag_total, frag_id, payload.len()); - AddressCodec.encode(target.clone().into(), &mut buf).expect("address encode"); - buf.extend_from_slice(payload); - buf.freeze() -} - -fn encode_packet_no_addr(assoc_id: u16, pkt_id: u16, frag_total: u8, frag_id: u8, payload: &[u8]) -> Bytes { - let mut buf = BytesMut::with_capacity(2 + 8 + 1 + payload.len()); - encode_packet_prefix(&mut buf, assoc_id, pkt_id, frag_total, frag_id, payload.len()); - AddressCodec.encode(Address::None, &mut buf).expect("address encode"); - buf.extend_from_slice(payload); - buf.freeze() -} - -fn encode_packet_prefix(buf: &mut BytesMut, assoc_id: u16, pkt_id: u16, frag_total: u8, frag_id: u8, size: usize) { - HeaderCodec.encode(Header::new(CmdType::Packet), buf).expect("header encode"); - CmdCodec(CmdType::Packet) - .encode( - Command::Packet { - assoc_id, - pkt_id, - frag_total, - frag_id, - size: size as u16, - }, - buf, - ) - .expect("packet cmd encode"); -} - -#[cfg(test)] -mod tests { - use std::net::Ipv4Addr; - - use super::*; - - fn connect_frame(target: &TargetAddr, payload: &[u8]) -> BytesMut { - let mut buf = BytesMut::new(); - HeaderCodec.encode(Header::new(CmdType::Connect), &mut buf).unwrap(); - CmdCodec(CmdType::Connect).encode(Command::Connect, &mut buf).unwrap(); - AddressCodec.encode(target.clone().into(), &mut buf).unwrap(); - buf.extend_from_slice(payload); - buf - } - - #[test] - fn parse_connect_full_frame_leaves_leftover_payload() { - let target = TargetAddr::IPv4(Ipv4Addr::LOCALHOST, 443); - let mut buf = connect_frame(&target, b"hello"); - - match try_parse_connect(&mut buf).unwrap() { - ParseOutcome::Connect(t) => assert_eq!(t, target), - _ => panic!("expected Connect"), - } - // After a successful parse the buffer holds only the leftover payload. - assert_eq!(&buf[..], b"hello"); - } - - #[test] - fn parse_connect_domain_target() { - let target = TargetAddr::Domain("example.com".to_string(), 8080); - let mut buf = connect_frame(&target, b""); - match try_parse_connect(&mut buf).unwrap() { - ParseOutcome::Connect(t) => assert_eq!(t, target), - _ => panic!("expected Connect"), - } - assert!(buf.is_empty()); - } - - #[test] - fn parse_connect_partial_is_need_more_and_preserves_buffer() { - let target = TargetAddr::IPv4(Ipv4Addr::LOCALHOST, 443); - let full = connect_frame(&target, b""); - // Feed every truncation; each must report NeedMore and leave the prefix - // intact (parse is non-destructive until it succeeds). - for cut in 1..full.len() { - let mut buf = BytesMut::from(&full[..cut]); - match try_parse_connect(&mut buf).unwrap() { - ParseOutcome::NeedMore => assert_eq!(&buf[..], &full[..cut]), - ParseOutcome::Connect(_) => panic!("unexpected Connect at cut {cut}"), - ParseOutcome::Bad => panic!("unexpected Bad at cut {cut}"), - } - } - } - - #[test] - fn parse_connect_rejects_non_connect() { - // A Heartbeat header on a bidi stream is not a valid CONNECT. - let mut buf = BytesMut::new(); - HeaderCodec.encode(Header::new(CmdType::Heartbeat), &mut buf).unwrap(); - buf.extend_from_slice(&[0u8; 8]); - assert!(matches!(try_parse_connect(&mut buf).unwrap(), ParseOutcome::Bad)); - } - - #[test] - fn encode_decode_packet_roundtrip() { - let target = TargetAddr::IPv4(Ipv4Addr::new(1, 2, 3, 4), 9000); - let payload = b"udp-datagram-body"; - let frame = encode_packet(7, 42, 1, 0, &target, payload); - - let mut buf = frame; - let header = decode_header(&mut buf, "t").unwrap(); - assert_eq!(header.command, CmdType::Packet); - let input = decode_packet(&mut buf).expect("decode packet"); - assert_eq!(input.assoc_id, 7); - assert_eq!(input.pkt_id, 42); - assert_eq!(input.frag_total, 1); - assert_eq!(input.frag_id, 0); - assert_eq!(input.target, target); - assert_eq!(&input.payload[..], payload); - } - - #[test] - fn encode_packet_no_addr_uses_none_address() { - let frame = encode_packet_no_addr(7, 42, 3, 1, b"frag"); - let mut buf = frame; - let header = decode_header(&mut buf, "t").unwrap(); - assert_eq!(header.command, CmdType::Packet); - let input = decode_packet(&mut buf).expect("decode packet"); - // Address::None maps to the unspecified-address sentinel. - assert_eq!(input.target, TargetAddr::IPv4(Ipv4Addr::UNSPECIFIED, 0)); - assert_eq!(input.frag_id, 1); - assert_eq!(input.frag_total, 3); - assert_eq!(&input.payload[..], b"frag"); - } -} diff --git a/crates/wind-tuiche/src/inbound.rs b/crates/wind-tuiche/src/inbound.rs deleted file mode 100644 index e6d8ca8..0000000 --- a/crates/wind-tuiche/src/inbound.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! TUIC inbound server implementation backed by `tokio-quiche`. - -use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration}; - -use futures_util::StreamExt; -use tokio::net::UdpSocket; -use tokio_quiche::{ - ConnectionParams, - metrics::DefaultMetrics, - settings::{CertificateKind, Hooks, QuicSettings, TlsCertificatePaths}, -}; -use tracing::{info, warn}; -use uuid::Uuid; -use wind_core::inbound::{AbstractInbound, InboundCallback}; - -use crate::{ - Result, - driver::TuicheDriver, - tls::{CertReloadHook, CertStore}, - utils::{ConnectionOpts, UdpRelayMode}, -}; - -/// TUIC server implementation using the `tokio-quiche` backend. -#[allow(dead_code)] -pub struct TuicheInbound { - listen_addr: SocketAddr, - users: HashMap>, - opts: ConnectionOpts, - /// Path to the PEM-encoded TLS certificate chain. - cert_path: String, - /// Path to the PEM-encoded private key. - private_key_path: String, - /// Hot-swappable certificate served to every handshake. Seeded from the - /// certificate files at build time; update via - /// [`TuicheInbound::cert_store`] for live rotation (e.g. ACME renewal) - /// without restarting the listener. - cert_store: CertStore, -} - -impl TuicheInbound { - /// Create a new TUIC server builder - pub fn builder() -> TuicheInboundBuilder { - TuicheInboundBuilder::new() - } - - /// Handle to the hot-swappable certificate store. Call - /// [`CertStore::update`] to rotate the served certificate live. - pub fn cert_store(&self) -> CertStore { - self.cert_store.clone() - } - - /// Translate [`ConnectionOpts`] into a [`tokio_quiche`] [`QuicSettings`]. - fn quic_settings(&self) -> QuicSettings { - let mut settings = QuicSettings::default(); - settings.max_idle_timeout = Some(self.opts.max_idle_timeout); - settings.initial_max_streams_bidi = self.opts.max_concurrent_bi_streams; - settings.initial_max_streams_uni = self.opts.max_concurrent_uni_streams; - settings.cc_algorithm = <&str>::from(self.opts.congestion_control).to_string(); - // Flow-control windows: size them from the configured receive window so - // bulk TCP relay isn't throttled by the conservative defaults. - settings.initial_max_data = self.opts.receive_window; - settings.initial_max_stream_data_bidi_local = self.opts.receive_window; - settings.initial_max_stream_data_bidi_remote = self.opts.receive_window; - settings.initial_max_stream_data_uni = self.opts.receive_window; - // TUIC relies on QUIC DATAGRAM frames (RFC 9221) for its native UDP relay - // mode, so the backend must advertise datagram support. - settings.enable_dgram = matches!(self.opts.udp_relay_mode, UdpRelayMode::Datagram); - // 0-RTT early data (resumption). TUIC has no app-layer replay protection, - // so this is opt-in via `ConnectionOpts::enable_0rtt`. - settings.enable_early_data = self.opts.enable_0rtt; - settings.alpn = vec![b"h3".to_vec()]; - settings - } -} - -impl AbstractInbound for TuicheInbound { - async fn listen(&self, cb: &impl InboundCallback) -> eyre::Result<()> { - info!("Starting wind-tuiche (tokio-quiche) inbound on {}", self.listen_addr); - - let socket = UdpSocket::bind(self.listen_addr).await?; - - // A `ConnectionHook` installs a per-handshake certificate-selection - // callback backed by `self.cert_store`, so certificate rotation (e.g. - // ACME renewal) takes effect without rebuilding the listener. The - // `TlsCertificatePaths` below are still required by tokio-quiche's API, - // but the hook supersedes them for the actual TLS context. - let hooks = Hooks { - connection_hook: Some(Arc::new(CertReloadHook::new(self.cert_store.clone()))), - }; - - let params = ConnectionParams::new_server( - self.quic_settings(), - TlsCertificatePaths { - cert: &self.cert_path, - private_key: &self.private_key_path, - kind: CertificateKind::X509, - }, - hooks, - ); - - let mut listeners = tokio_quiche::listen([socket], params, DefaultMetrics) - .map_err(|e| eyre::eyre!("failed to start tokio-quiche listener: {e}"))?; - let mut stream = listeners.remove(0); - - // Shared registered-user table handed to every connection's driver. - let users = Arc::new(self.users.clone()); - - info!("wind-tuiche listening loop started"); - - while let Some(conn) = stream.next().await { - match conn { - Ok(conn) => { - // Each connection gets its own TUIC protocol driver running on - // the tokio-quiche worker. `start` spawns the worker and - // returns a handle we don't need to retain. A per-connection - // span tags every driver log line (and the relay tasks it - // spawns) with the peer address, mirroring `wind-tuic`. - let span = tracing::info_span!("conn", peer = %conn.peer_addr()); - let driver = TuicheDriver::new(cb.clone(), users.clone(), span); - conn.start(driver); - } - Err(e) => { - warn!("wind-tuiche listener error: {e}"); - } - } - } - - Ok(()) - } -} - -/// Builder for [`TuicheInbound`]. -pub struct TuicheInboundBuilder { - listen_addr: Option, - users: HashMap>, - cert_path: Option, - private_key_path: Option, - opts: ConnectionOpts, -} - -impl TuicheInboundBuilder { - /// Create a new builder - pub fn new() -> Self { - Self { - listen_addr: None, - users: HashMap::new(), - cert_path: None, - private_key_path: None, - opts: ConnectionOpts::default(), - } - } - - /// Set the listen address - pub fn listen_addr(mut self, addr: SocketAddr) -> Self { - self.listen_addr = Some(addr); - self - } - - /// Add a user - pub fn user(mut self, uuid: Uuid, password: String) -> Self { - self.users.insert(uuid, password.into_bytes()); - self - } - - /// Set the path to the PEM-encoded TLS certificate chain. - pub fn certificate_path(mut self, path: impl Into) -> Self { - self.cert_path = Some(path.into()); - self - } - - /// Set the path to the PEM-encoded private key. - pub fn private_key_path(mut self, path: impl Into) -> Self { - self.private_key_path = Some(path.into()); - self - } - - /// Set maximum idle time - pub fn max_idle_time(mut self, time: Duration) -> Self { - self.opts.max_idle_timeout = time; - self - } - - /// Set connection options - pub fn connection_opts(mut self, opts: ConnectionOpts) -> Self { - self.opts = opts; - self - } - - /// Build the server. - /// - /// Reads the certificate and key files to seed the hot-swappable - /// [`CertStore`]; both paths are required (tokio-quiche servers must - /// present TLS credentials). - pub async fn build(self) -> Result { - let listen_addr = self.listen_addr.ok_or_else(|| eyre::eyre!("Listen address not set"))?; - let cert_path = self - .cert_path - .ok_or_else(|| eyre::eyre!("wind-tuiche inbound requires a certificate (set certificate_path)"))?; - let private_key_path = self - .private_key_path - .ok_or_else(|| eyre::eyre!("wind-tuiche inbound requires a private key (set private_key_path)"))?; - - let cert_pem = std::fs::read(&cert_path).map_err(|e| eyre::eyre!("reading certificate {}: {e}", cert_path))?; - let key_pem = - std::fs::read(&private_key_path).map_err(|e| eyre::eyre!("reading private key {}: {e}", private_key_path))?; - let cert_store = CertStore::from_pem(&cert_pem, &key_pem)?; - - Ok(TuicheInbound { - listen_addr, - users: self.users, - opts: self.opts, - cert_path, - private_key_path, - cert_store, - }) - } -} - -impl Default for TuicheInboundBuilder { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/wind-tuiche/src/lib.rs b/crates/wind-tuiche/src/lib.rs deleted file mode 100644 index 023bf47..0000000 --- a/crates/wind-tuiche/src/lib.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! TUIC (TCP/UDP over QUIC) implementation built on the [`tokio-quiche`] -//! backend. -//! -//! This crate mirrors the public surface of the quinn-backed `wind-tuic` -//! crate, but drives the underlying QUIC stack with Cloudflare's -//! [`tokio-quiche`] library instead of quinn. -//! -//! [`tokio-quiche`]: https://docs.rs/tokio-quiche - -pub mod task; -pub mod utils; - -#[cfg(feature = "server")] -mod driver; -#[cfg(feature = "server")] -mod stream; -#[cfg(feature = "server")] -pub mod tls; - -/// Backend-agnostic TUIC wire codecs and decode helpers, shared with -/// `wind-tuic` via the [`tuic_core`] crate. -pub use tuic_core::proto; -/// Backend-agnostic UDP fragment reassembly state machine. -pub use tuic_core::udp; -pub use utils::{CongestionControl, ConnectionOpts, ConnectionStats, UdpRelayMode}; - -#[cfg(feature = "server")] -pub mod inbound; - -#[cfg(feature = "client")] -pub mod outbound; - -#[cfg(feature = "server")] -pub use inbound::{TuicheInbound, TuicheInboundBuilder}; -#[cfg(feature = "client")] -pub use outbound::{TuicheOutbound, TuicheOutboundBuilder}; -#[cfg(feature = "server")] -pub use tls::CertStore; - -pub type Error = eyre::Report; -pub type Result = eyre::Result; diff --git a/crates/wind-tuiche/src/stream.rs b/crates/wind-tuiche/src/stream.rs deleted file mode 100644 index 20a7043..0000000 --- a/crates/wind-tuiche/src/stream.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! Channel-backed duplex stream bridging a quiche bidirectional stream (driven -//! by the [`crate::driver::TuicheDriver`] worker) to the `wind-core` -//! [`InboundCallback::handle_tcpstream`](wind_core::InboundCallback) relay. -//! -//! The `tokio-quiche` worker owns the `quiche::Connection` and can only touch -//! streams synchronously from inside the `ApplicationOverQuic` callbacks. The -//! TUIC TCP relay, however, wants an owned `AsyncRead + AsyncWrite` handle it -//! can hand to the outbound `copy_io`. [`QuicheStream`] is that handle: its -//! read half is fed by the worker (data the client sent on the QUIC stream) and -//! its write half drains to the worker (data to send back to the client). - -use std::{ - future::Future, - io, - pin::Pin, - task::{Context, Poll}, -}; - -use bytes::Bytes; -use tokio::{ - io::{AsyncRead, AsyncWrite, ReadBuf}, - sync::mpsc, -}; -use tokio_util::sync::PollSender; - -/// Largest chunk copied per `poll_write`, bounding the size of a single `Bytes` -/// handed to the worker. -const WRITE_CHUNK: usize = 16 * 1024; - -/// Duplex stream handed to `InboundCallback::handle_tcpstream`. -pub struct QuicheStream { - /// Client → target payload, fed by the worker as it drains the QUIC stream. - /// `None` from the channel (sender dropped) signals the client's FIN → EOF. - read_rx: mpsc::Receiver, - read_leftover: Bytes, - /// Target → client payload, drained by the worker for `stream_send`. - write_tx: PollSender, -} - -impl QuicheStream { - pub fn new(read_rx: mpsc::Receiver, write_tx: mpsc::Sender) -> Self { - Self { - read_rx, - read_leftover: Bytes::new(), - write_tx: PollSender::new(write_tx), - } - } -} - -impl AsyncRead for QuicheStream { - fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { - if self.read_leftover.is_empty() { - match self.read_rx.poll_recv(cx) { - Poll::Ready(Some(b)) => self.read_leftover = b, - // Sender dropped → client finished sending → clean EOF. - Poll::Ready(None) => return Poll::Ready(Ok(())), - Poll::Pending => return Poll::Pending, - } - } - let n = self.read_leftover.len().min(buf.remaining()); - let chunk = self.read_leftover.split_to(n); - buf.put_slice(&chunk); - Poll::Ready(Ok(())) - } -} - -impl AsyncWrite for QuicheStream { - fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, data: &[u8]) -> Poll> { - match self.write_tx.poll_reserve(cx) { - Poll::Ready(Ok(())) => { - let n = data.len().min(WRITE_CHUNK); - match self.write_tx.send_item(Bytes::copy_from_slice(&data[..n])) { - Ok(()) => Poll::Ready(Ok(n)), - Err(_) => Poll::Ready(Err(io::Error::new(io::ErrorKind::BrokenPipe, "quic stream closed"))), - } - } - Poll::Ready(Err(_)) => Poll::Ready(Err(io::Error::new(io::ErrorKind::BrokenPipe, "quic stream closed"))), - Poll::Pending => Poll::Pending, - } - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - // Closing the sender makes the worker observe end-of-data on this - // stream's back-channel and emit a FIN on the QUIC stream. - self.write_tx.close(); - Poll::Ready(Ok(())) - } -} - -/// Result of polling a TCP back-channel: data to send to the client, or -/// end-of-stream when `data` is `None`. -pub struct TcpBack { - pub stream_id: u64, - pub data: Option, - pub rx: mpsc::Receiver, -} - -/// A self-contained future that resolves when a TCP stream's back-channel has a -/// new chunk (or closes). On resolution it returns the receiver so the worker -/// can re-arm it. Modelled on the rusteria/tokio-quiche stream-waiter pattern. -pub struct WaitTcpBack { - stream_id: u64, - rx: Option>, -} - -impl WaitTcpBack { - pub fn new(stream_id: u64, rx: mpsc::Receiver) -> Self { - Self { stream_id, rx: Some(rx) } - } -} - -impl Future for WaitTcpBack { - type Output = TcpBack; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let stream_id = self.stream_id; - let rx = self.rx.as_mut().expect("WaitTcpBack polled after completion"); - match rx.poll_recv(cx) { - Poll::Ready(data) => { - let rx = self.rx.take().unwrap(); - Poll::Ready(TcpBack { stream_id, data, rx }) - } - Poll::Pending => Poll::Pending, - } - } -} diff --git a/crates/wind-tuiche/src/task.rs b/crates/wind-tuiche/src/task.rs deleted file mode 100644 index f1d08f1..0000000 --- a/crates/wind-tuiche/src/task.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Task management utilities - -use std::time::Duration; - -use tokio::time; - -/// Error indicating a task timed out -#[derive(Debug, thiserror::Error)] -#[error("Task timed out")] -#[allow(dead_code)] -pub struct TimeoutError; - -#[allow(dead_code)] -pub async fn with_timeout(timeout: Duration, future: F) -> Result -where - F: std::future::Future, -{ - match tokio::time::timeout(timeout, future).await { - Ok(res) => Ok(res), - Err(_) => Err(TimeoutError), - } -} - -#[allow(dead_code)] -pub async fn with_retries( - max_retries: u32, - initial_delay: Duration, - backoff_factor: f64, - mut operation: F, -) -> Result -where - F: FnMut() -> Fut, - Fut: std::future::Future>, - E: std::fmt::Debug, -{ - let mut delay = initial_delay; - - for attempt in 0..=max_retries { - match operation().await { - Ok(result) => return Ok(result), - Err(e) => { - if attempt == max_retries { - return Err(e); - } - - // Wait before retrying - time::sleep(delay).await; - - // Increase delay for next retry - delay = Duration::from_secs_f64(delay.as_secs_f64() * backoff_factor); - } - } - } - - unreachable!() -} diff --git a/crates/wind-tuiche/src/utils.rs b/crates/wind-tuiche/src/utils.rs deleted file mode 100644 index f31b96e..0000000 --- a/crates/wind-tuiche/src/utils.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Utility functions and types for wind-tuiche - -use std::time::Duration; - -/// Congestion control algorithm -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CongestionControl { - #[default] - Cubic, - Bbr, - Reno, -} - -impl From for &str { - fn from(cc: CongestionControl) -> Self { - match cc { - CongestionControl::Cubic => "cubic", - CongestionControl::Bbr => "bbr", - CongestionControl::Reno => "reno", - } - } -} - -/// UDP relay mode -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum UdpRelayMode { - #[default] - Datagram, - Stream, -} - -/// Connection options -#[derive(Debug, Clone)] -pub struct ConnectionOpts { - /// Maximum idle timeout - pub max_idle_timeout: Duration, - /// Maximum concurrent bidirectional streams - pub max_concurrent_bi_streams: u64, - /// Maximum concurrent unidirectional streams - pub max_concurrent_uni_streams: u64, - /// Send window size - pub send_window: u64, - /// Receive window size - pub receive_window: u64, - /// Congestion control algorithm - pub congestion_control: CongestionControl, - /// UDP relay mode - pub udp_relay_mode: UdpRelayMode, - /// Enable 0-RTT - pub enable_0rtt: bool, -} - -impl Default for ConnectionOpts { - fn default() -> Self { - Self { - max_idle_timeout: Duration::from_secs(30), - max_concurrent_bi_streams: 100, - max_concurrent_uni_streams: 100, - send_window: 8 * 1024 * 1024, // 8 MB - receive_window: 8 * 1024 * 1024, // 8 MB - congestion_control: CongestionControl::default(), - udp_relay_mode: UdpRelayMode::default(), - enable_0rtt: true, - } - } -} - -/// Connection statistics -#[derive(Debug, Clone, Default)] -pub struct ConnectionStats { - /// Total bytes sent - pub bytes_sent: u64, - /// Total bytes received - pub bytes_received: u64, - /// Packets sent - pub packets_sent: u64, - /// Packets received - pub packets_received: u64, - /// Lost packets - pub packets_lost: u64, - /// Retransmitted packets - pub packets_retransmitted: u64, -} diff --git a/crates/wind-tuiche/tests/integration_test.rs b/crates/wind-tuiche/tests/integration_test.rs deleted file mode 100644 index d271fd4..0000000 --- a/crates/wind-tuiche/tests/integration_test.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Integration tests for wind-tuiche - -use std::time::Duration; - -use wind_tuiche::{CongestionControl, ConnectionOpts, UdpRelayMode}; - -#[test] -fn test_congestion_control() { - let cc = CongestionControl::default(); - assert_eq!(cc, CongestionControl::Cubic); - - let cc = CongestionControl::Bbr; - let s: &str = cc.into(); - assert_eq!(s, "bbr"); - - let cc = CongestionControl::Reno; - let s: &str = cc.into(); - assert_eq!(s, "reno"); -} - -#[test] -fn test_udp_relay_mode() { - let mode = UdpRelayMode::default(); - assert_eq!(mode, UdpRelayMode::Datagram); - - let mode = UdpRelayMode::Stream; - assert_ne!(mode, UdpRelayMode::default()); -} - -#[test] -fn test_connection_opts_default() { - let opts = ConnectionOpts::default(); - - assert_eq!(opts.max_idle_timeout, Duration::from_secs(30)); - assert_eq!(opts.max_concurrent_bi_streams, 100); - assert_eq!(opts.max_concurrent_uni_streams, 100); - assert_eq!(opts.send_window, 8 * 1024 * 1024); - assert_eq!(opts.receive_window, 8 * 1024 * 1024); - assert_eq!(opts.congestion_control, CongestionControl::Cubic); - assert_eq!(opts.udp_relay_mode, UdpRelayMode::Datagram); - assert!(opts.enable_0rtt); -}