dwara_core/dataplane/
l4.rs

1//! L4 TCP/UDP proxying with SNI routing reuse (DW-103, DP-01 / #234:
2//! UDP datagram forwarding).
3//!
4//! L4 proxying is a LISTENER TYPE, not a route action: a `protocol: tcp`
5//! (or `protocol: udp`) listener accepts raw L4 connections and splices
6//! them byte-for-byte to an upstream endpoint, never running the HTTP
7//! pipeline. This is the same byte-splice model the DW-008 TLS
8//! passthrough path uses -- and when `sni_routing` is true, the TCP
9//! dispatcher reuses the EXACT SNI extraction from passthrough
10//! (`security::tls::sni_from_client_hello` + the peek loop) to select
11//! the upstream from the listener's `tls.sni_routes`.
12//!
13//! ## TCP dispatcher
14//!
15//! [`L4Dispatcher`] (built from [`L4ProxyConfig`]) accepts a TCP
16//! connection and:
17//!
18//! 1. If `sni_routing` is true, peeks the TLS ClientHello SNI (reusing
19//!    `security::tls::handle_passthrough`'s peek + `sni_from_client_hello`
20//!    extraction -- the same bounded reassembly, the same 64 KiB budget,
21//!    the same 10s peek timeout). The SNI is matched against the
22//!    listener's `tls.sni_routes` to select the upstream; the configured
23//!    `upstream` is the fallback for no-SNI / unmatched names (absent =
24//!    close).
25//! 2. If `sni_routing` is false, the configured `upstream` receives
26//!    every connection.
27//! 3. The selected upstream's endpoint is picked through the CURRENT
28//!    generation's balancers (no hash key -- a byte splice has no
29//!    client-IP semantics), so L4 picks follow config reloads.
30//! 4. The client and upstream connections are spliced with
31//!    `tokio::io::copy_bidirectional` until either side closes (the
32//!    same tunnel the passthrough path and the 101 upgrade path use).
33//!    An optional idle timeout closes the splice when neither side sends
34//!    data for the configured duration.
35//!
36//! Peeking (never reading) keeps the ClientHello bytes available for the
37//! upstream once splicing starts: the entire hello is still in the
38//! socket buffer and is replayed to the upstream by the splice.
39//!
40//! ## UDP dispatcher (DP-01, #234)
41//!
42//! [`UdpDispatcher`] implements UDP datagram forwarding with per-client
43//! session tracking. Each unique client `SocketAddr` gets a dedicated
44//! upstream `UdpSocket`; datagrams from the client are forwarded to the
45//! upstream, and responses from the upstream are forwarded back to the
46//! client. Sessions expire after a configurable idle timeout (default
47//! 30s) to avoid leaking NAT state. The dispatcher uses a bounded
48//! session table (`max_sessions`, default 4096) to prevent resource
49//! exhaustion under adversarial traffic.
50//!
51//! ## Availability
52//!
53//! The entire module is compiled into the OSS build. The config
54//! schema (`ListenerProtocol::Tcp`/`Udp` + `L4Config`) is always
55//! present so configs round-trip; L4 proxying is always available.
56
57use std::collections::HashMap;
58use std::net::SocketAddr;
59use std::time::Duration;
60
61use tokio::io::AsyncWriteExt;
62use tokio::net::TcpStream;
63use tokio::net::UdpSocket;
64
65use crate::config::{Gateway, L4Config, SniRoute};
66use crate::dataplane::proxy::DataPlane;
67use crate::security::tls::{self, EndpointPicker, PassthroughAction};
68
69/// L4 proxying error. Kept simple: the dispatcher logs and closes on
70/// any error -- there is no client-facing HTTP envelope at L4.
71#[derive(Debug)]
72pub enum L4Error {
73    /// The configured upstream was not found in the current snapshot.
74    UnknownUpstream(String),
75    /// The upstream has no healthy / configured endpoints to splice to.
76    NoEndpoint(String),
77    /// The upstream connection failed (connect error, EOF, etc.).
78    Io(std::io::Error),
79    /// UDP proxying is not yet implemented (DW-103 follow-up).
80    Unimplemented,
81}
82
83impl std::fmt::Display for L4Error {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            L4Error::UnknownUpstream(name) => {
87                write!(f, "unknown upstream '{name}'")
88            }
89            L4Error::NoEndpoint(name) => {
90                write!(f, "upstream '{name}' has no available endpoint")
91            }
92            L4Error::Io(err) => write!(f, "l4 splice io error: {err}"),
93            L4Error::Unimplemented => {
94                write!(
95                    f,
96                    "UDP L4 proxying is not yet implemented (DW-103 follow-up)"
97                )
98            }
99        }
100    }
101}
102
103impl std::error::Error for L4Error {
104    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
105        match self {
106            L4Error::Io(err) => Some(err),
107            _ => None,
108        }
109    }
110}
111
112impl From<std::io::Error> for L4Error {
113    fn from(err: std::io::Error) -> Self {
114        L4Error::Io(err)
115    }
116}
117
118/// Compiled L4 proxying configuration (from [`L4Config`]).
119///
120/// `upstream` is the fallback / fixed upstream name; `sni_routing`
121/// selects per-connection routing via the TLS ClientHello SNI. The
122/// `idle_timeout` closes the splice after the configured inactivity
123/// gap (None = no timeout). `session_timeout` and `max_sessions` are
124/// UDP-specific: the session timeout expires idle NAT sessions, and
125/// `max_sessions` bounds the session table.
126#[derive(Debug, Clone)]
127pub struct L4ProxyConfig {
128    /// The configured (or fallback) upstream name. None when
129    /// `sni_routing` is true and no fallback is configured (close on
130    /// no match).
131    pub upstream: Option<String>,
132    /// Peek the TLS ClientHello SNI and route via `tls.sni_routes`.
133    pub sni_routing: bool,
134    /// Idle timeout for an established splice. None = no timeout.
135    pub idle_timeout: Option<Duration>,
136    /// DP-01 (#234): UDP session idle timeout (default 30s). Sessions
137    /// that receive no datagrams for this duration are evicted from the
138    /// session table.
139    pub session_timeout: Duration,
140    /// DP-01 (#234): maximum concurrent UDP sessions (default 4096).
141    /// When the table is full, new sessions replace the oldest idle
142    /// session (LRU eviction).
143    pub max_sessions: usize,
144}
145
146impl L4ProxyConfig {
147    /// Build from the config schema.
148    pub fn from_config(cfg: &L4Config) -> Self {
149        Self {
150            upstream: cfg.upstream.clone(),
151            sni_routing: cfg.sni_routing,
152            idle_timeout: cfg.idle_timeout_s.map(Duration::from_secs),
153            session_timeout: Duration::from_secs(cfg.session_timeout_s.unwrap_or(30)),
154            max_sessions: cfg.max_sessions.unwrap_or(4096),
155        }
156    }
157}
158
159/// The outcome of an L4 TCP dispatch: which upstream endpoint the
160/// connection was spliced to (for metrics/logging), or that it was
161/// closed (no upstream, no endpoint, or SNI routing with no match).
162#[derive(Debug, PartialEq, Eq)]
163pub enum L4DispatchAction {
164    /// The splice was established to this endpoint.
165    Forward { host: String, port: u16 },
166    /// The connection was closed (no upstream, no endpoint, no SNI
167    /// match, or a connect/splice error).
168    Close,
169}
170
171/// TCP L4 dispatcher: accept one TCP connection, optionally peek SNI,
172/// select the upstream, and splice byte-for-byte.
173///
174/// The dispatcher is stateless beyond the config -- each call handles
175/// one connection. The `dp` (dataplane) supplies the CURRENT
176/// generation's balancers so endpoint picks follow config reloads.
177pub struct L4Dispatcher {
178    config: L4ProxyConfig,
179    /// The listener's SNI routes (used when `sni_routing` is true).
180    /// Borrowed from the live snapshot; the caller holds the snapshot
181    /// alive for the duration of the dispatch.
182    sni_routes: Vec<SniRoute>,
183}
184
185impl L4Dispatcher {
186    /// Build a dispatcher from the compiled config and the listener's
187    /// SNI routes (empty when `sni_routing` is false).
188    pub fn new(config: L4ProxyConfig, sni_routes: Vec<SniRoute>) -> Self {
189        Self { config, sni_routes }
190    }
191
192    /// Dispatch one accepted TCP connection: peek SNI (if enabled),
193    /// select the upstream endpoint, splice byte-for-byte, and return
194    /// the action taken.
195    ///
196    /// `dp` supplies the current generation's balancers (endpoint
197    /// picks follow config reloads); `gateway` is the current snapshot
198    /// (for SNI route resolution and the first-endpoint fallback).
199    pub async fn dispatch(
200        &self,
201        stream: &mut TcpStream,
202        dp: &DataPlane,
203        gateway: &Gateway,
204    ) -> Result<L4DispatchAction, L4Error> {
205        // 1. Select the upstream name (fixed, or SNI-routed).
206        let (host, port) = if self.config.sni_routing {
207            // Reuse the EXACT peek + SNI extraction from DW-008
208            // passthrough (peek_client_hello_sni is the peek-only half
209            // of handle_passthrough, extracted for this path). We own
210            // the splice (idle timeout, metrics) rather than letting
211            // handle_passthrough splice for us.
212            let sni = tls::peek_client_hello_sni(stream).await?;
213            let pick: EndpointPicker<'_> = &|name: &str| {
214                dp.registry()
215                    .get(name)
216                    .and_then(|h| h.lb().pick_endpoint(None).map(|(_, a, p)| (a, p)))
217            };
218            match tls::resolve_passthrough(sni.as_deref(), &self.sni_routes, gateway, Some(pick)) {
219                PassthroughAction::Forward { host, port } => (host, port),
220                PassthroughAction::Close => {
221                    // SNI routing with no match: try the fallback
222                    // upstream, or close.
223                    match &self.config.upstream {
224                        Some(name) => pick_endpoint(dp, gateway, name)?,
225                        None => {
226                            let _ = stream.shutdown().await;
227                            return Ok(L4DispatchAction::Close);
228                        }
229                    }
230                }
231            }
232        } else {
233            // Fixed upstream: every connection goes to the configured
234            // upstream.
235            let name = self
236                .config
237                .upstream
238                .as_deref()
239                .ok_or_else(|| L4Error::UnknownUpstream("(none configured)".into()))?;
240            pick_endpoint(dp, gateway, name)?
241        };
242
243        // 2. Connect to the upstream endpoint.
244        let mut upstream = TcpStream::connect((host.as_str(), port)).await?;
245        let _ = stream.set_nodelay(true);
246        let _ = upstream.set_nodelay(true);
247
248        // 3. Splice byte-for-byte (the same tunnel passthrough and the
249        //    101 upgrade path use). The optional idle timeout wraps the
250        //    splice: if neither side sends data for the configured
251        //    duration, the splice is aborted.
252        let action = L4DispatchAction::Forward {
253            host: host.clone(),
254            port,
255        };
256        splice_with_idle(stream, &mut upstream, self.config.idle_timeout).await?;
257        Ok(action)
258    }
259}
260
261/// One UDP session: a per-client upstream socket and the last-active
262/// timestamp (for idle eviction).
263struct UdpSession {
264    /// The upstream socket dedicated to this client session.
265    upstream: std::sync::Arc<UdpSocket>,
266    /// Last activity timestamp (Unix epoch milliseconds).
267    last_active_ms: u64,
268}
269
270/// UDP L4 dispatcher (DP-01, #234): per-client session tracking with
271/// datagram forwarding and idle-session eviction.
272///
273/// The dispatcher owns the listener-side `UdpSocket` and a session
274/// table keyed by client `SocketAddr`. Each unique client gets a
275/// dedicated upstream `UdpSocket`; datagrams from the client are
276/// forwarded to the upstream, and responses from the upstream are
277/// forwarded back to the client. Sessions that are idle for longer
278/// than `session_timeout` are evicted.
279pub struct UdpDispatcher {
280    config: L4ProxyConfig,
281}
282
283impl UdpDispatcher {
284    /// Build a UDP dispatcher from the compiled config.
285    pub fn new(config: L4ProxyConfig) -> Self {
286        Self { config }
287    }
288
289    /// Run the UDP dispatch loop: receive datagrams on the listener
290    /// socket, forward them to per-client upstream sockets, and
291    /// forward responses back. Sessions are evicted after the idle
292    /// timeout.
293    ///
294    /// This method runs until the listener socket is closed (graceful
295    /// shutdown). It spawns two tasks per session: one for
296    /// client->upstream forwarding (handled inline on recv) and one
297    /// for upstream->client forwarding (spawned per session).
298    pub async fn run(
299        &self,
300        listener: std::sync::Arc<UdpSocket>,
301        dp: &DataPlane,
302        gateway: &Gateway,
303    ) -> Result<(), L4Error> {
304        let name = self
305            .config
306            .upstream
307            .as_deref()
308            .ok_or_else(|| L4Error::UnknownUpstream("(none configured)".into()))?;
309        let (upstream_host, upstream_port) = pick_endpoint(dp, gateway, name)?;
310
311        let upstream_addr: SocketAddr = format!("{}:{}", upstream_host, upstream_port)
312            .parse()
313            .map_err(|e| {
314                L4Error::Io(std::io::Error::other(format!("invalid upstream addr: {e}")))
315            })?;
316
317        let mut sessions: HashMap<SocketAddr, UdpSession> = HashMap::new();
318        let mut buf = vec![0u8; 65507]; // max UDP datagram payload
319        let session_timeout = self.config.session_timeout;
320        let max_sessions = self.config.max_sessions;
321
322        loop {
323            // Receive with a timeout so we can periodically evict idle
324            // sessions even when traffic stops.
325            let recv_result =
326                tokio::time::timeout(Duration::from_secs(1), listener.recv_from(&mut buf)).await;
327
328            // Evict idle sessions regardless of whether we received.
329            let now_ms = std::time::SystemTime::now()
330                .duration_since(std::time::UNIX_EPOCH)
331                .map(|d| d.as_millis() as u64)
332                .unwrap_or(0);
333            let timeout_ms = session_timeout.as_millis() as u64;
334            sessions
335                .retain(|_, session| now_ms.saturating_sub(session.last_active_ms) < timeout_ms);
336
337            match recv_result {
338                Ok(Ok((len, client_addr))) => {
339                    let datagram = &buf[..len];
340
341                    // Get or create the session for this client.
342                    let session = match sessions.get_mut(&client_addr) {
343                        Some(s) => {
344                            s.last_active_ms = now_ms;
345                            s
346                        }
347                        None => {
348                            // Evict the oldest session if at capacity.
349                            if sessions.len() >= max_sessions {
350                                if let Some((oldest_key, _)) = sessions
351                                    .iter()
352                                    .min_by_key(|(_, s)| s.last_active_ms)
353                                    .map(|(k, v)| (*k, v.last_active_ms))
354                                {
355                                    sessions.remove(&oldest_key);
356                                }
357                            }
358                            // Create a new upstream socket for this client.
359                            let upstream_sock =
360                                UdpSocket::bind("0.0.0.0:0").await.map_err(L4Error::Io)?;
361                            upstream_sock
362                                .connect(upstream_addr)
363                                .await
364                                .map_err(L4Error::Io)?;
365                            let upstream = std::sync::Arc::new(upstream_sock);
366                            sessions.insert(
367                                client_addr,
368                                UdpSession {
369                                    upstream: std::sync::Arc::clone(&upstream),
370                                    last_active_ms: now_ms,
371                                },
372                            );
373                            // Spawn the upstream->client forwarder.
374                            let listener = std::sync::Arc::clone(&listener);
375                            tokio::spawn(async move {
376                                let mut resp_buf = vec![0u8; 65507];
377                                while let Ok(resp_len) = upstream.recv(&mut resp_buf).await {
378                                    if listener
379                                        .send_to(&resp_buf[..resp_len], client_addr)
380                                        .await
381                                        .is_err()
382                                    {
383                                        break;
384                                    }
385                                }
386                            });
387                            sessions.get_mut(&client_addr).unwrap()
388                        }
389                    };
390
391                    // Forward the datagram to the upstream.
392                    let _ = session.upstream.send(datagram).await;
393                }
394                Ok(Err(e)) => {
395                    tracing::debug!(code = "l4_udp_recv_error", "udp recv error: {e}");
396                    return Err(L4Error::Io(e));
397                }
398                Err(_) => {
399                    // Timeout — continue the loop to evict idle sessions.
400                    continue;
401                }
402            }
403        }
404    }
405}
406
407/// Pick a load-balanced endpoint for the named upstream through the
408/// current generation's balancers (no hash key -- a byte splice has no
409/// client-IP semantics). Falls back to the first configured endpoint
410/// when the balancer has no healthy endpoint.
411fn pick_endpoint(dp: &DataPlane, gateway: &Gateway, name: &str) -> Result<(String, u16), L4Error> {
412    if let Some(h) = dp.registry().get(name) {
413        if let Some((_, addr, port)) = h.lb().pick_endpoint(None) {
414            return Ok((addr, port));
415        }
416    }
417    // Fallback: the first configured endpoint (the same fallback
418    // resolve_passthrough uses when there is no dataplane resolver).
419    if let Some(upstream) = gateway.upstreams.iter().find(|u| u.name == name) {
420        if let Some(e) = upstream.endpoints.first() {
421            return Ok((e.address.clone(), e.port));
422        }
423    }
424    if dp.registry().get(name).is_some() {
425        Err(L4Error::NoEndpoint(name.to_string()))
426    } else {
427        Err(L4Error::UnknownUpstream(name.to_string()))
428    }
429}
430
431/// Splice two TCP streams byte-for-byte with an optional idle timeout.
432/// The idle timeout wraps the whole splice: if neither side sends data
433/// for the configured duration, both sides are shut down and the splice
434/// returns. Without a timeout the splice runs until either side closes
435/// (the same `copy_bidirectional` semantics as passthrough).
436async fn splice_with_idle(
437    client: &mut TcpStream,
438    upstream: &mut TcpStream,
439    idle_timeout: Option<Duration>,
440) -> Result<(), L4Error> {
441    match idle_timeout {
442        Some(timeout) => {
443            let splice = tokio::io::copy_bidirectional(client, upstream);
444            match tokio::time::timeout(timeout, splice).await {
445                Ok(Ok(_)) => {}
446                Ok(Err(err)) => return Err(L4Error::Io(err)),
447                Err(_) => {
448                    tracing::debug!(code = "l4_idle_timeout", "l4 splice idle timeout");
449                }
450            }
451            let _ = client.shutdown().await;
452            let _ = upstream.shutdown().await;
453        }
454        None => {
455            match tokio::io::copy_bidirectional(client, upstream).await {
456                Ok(_) => {}
457                Err(err) => {
458                    // A splice error (one side reset mid-stream) is
459                    // expected at L4 -- log and close, do not propagate
460                    // as a hard error to the caller (the connection is
461                    // gone either way).
462                    tracing::debug!(code = "l4_splice_error", "l4 splice ended: {err}");
463                }
464            }
465            let _ = client.shutdown().await;
466            let _ = upstream.shutdown().await;
467        }
468    }
469    Ok(())
470}