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}