sozu_lib/protocol/udp/mod.rs
1//! Pure sans-io UDP load-balancing core.
2//!
3//! This module is the *pure* heart of the UDP datapath (issue #1273). It owns
4//! admission, the virtual 4-tuple flow table, the three-knob teardown
5//! state-machine, the LB-selection request protocol, and a single-deadline
6//! timer scheduler that revalidates flow deadlines on expiry. It performs
7//! **no I/O**: there is no socket, no `Instant::now()` / `SystemTime`, no
8//! `rand`, and no `Arc<Mutex>`.
9//! Time is injected as `now: Instant` parameters; the hash seed is injected at
10//! construction. The single admission copy the design allows materialises the
11//! borrowed recv buffer into an owned `Vec<u8>` ([`Transmit::payload`]).
12//!
13//! The I/O shell ([`crate::udp`]) owns every syscall, the buffer pool, the timer
14//! wheel, the connected per-flow upstream sockets, the `BackendMap`, health
15//! checks and metrics. It drives this core through [`ManagerInput`] / [`Output`].
16//!
17//! Two-level split (mirrors the H2 `ConnectionH2` / `Context` split in
18//! `protocol/mux/`):
19//! - [`manager::UdpManager`]: flow table + admission + cap/shedding
20//! + flow-key extraction + LB request + timer scheduling.
21//! - [`flow::UdpFlow`]: per-admitted-flow teardown counters, idle /
22//! lifetime deadlines, PPv2 bookkeeping, chosen backend, forward/return
23//! decisions, and a `timer_gen` refresh counter.
24//!
25//! Long-form lifecycle (flow state machine, NAT return, teardown, hardening):
26//! `lib/src/protocol/udp/LIFECYCLE.md`.
27
28pub mod flow;
29pub mod manager;
30pub mod proxy_protocol;
31
32use std::{
33 net::SocketAddr,
34 time::{Duration, Instant},
35};
36
37use sozu_command::state::ClusterId;
38
39pub use crate::protocol::udp::{
40 flow::{CloseReason, UdpFlow},
41 manager::{FlowKeyExtractor, SourceTupleExtractor, UdpManager},
42};
43
44/// Slab index of an admitted flow. Stable for the flow's lifetime; reused after
45/// close. The shell maps `upstream_token -> FlowId` for the NAT return path.
46pub type FlowId = usize;
47
48/// Backend identifier, mirroring [`sozu_command::state`]'s string backend ids
49/// and `Backend::backend_id` in `lib/src/backends.rs`.
50pub type BackendId = String;
51
52/// The virtual flow key extracted from a client datagram. The default
53/// [`SourceTupleExtractor`] keys on the real (pre-NAT) client source address,
54/// IP **and** port, whatever the cluster's affinity key: one client socket is
55/// one flow with one connected upstream socket, so every backend reply returns
56/// to the socket it answers. `affinity_key` only feeds backend selection (the
57/// affinity hash), where `SOURCE_IP` makes every port of one IP land on the
58/// same backend (#1732). Other extractors may key differently — the trait is
59/// the only seam — but the 4-tuple impl is the only one in scope.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
61pub struct FlowKey {
62 /// The client source address (IP and port).
63 pub src: SocketAddr,
64}
65
66impl FlowKey {
67 /// Build a key from a client source address.
68 pub fn from_src(src: SocketAddr) -> Self {
69 FlowKey { src }
70 }
71}
72
73/// An owned datagram ready to be written by the shell. `payload` is the single
74/// admission copy (`Vec<u8>`); for the first upstream datagram of a PPv2 flow
75/// the core has already prepended the v2 DGRAM header, so the shell writes
76/// `payload` verbatim. `segment_size` reserves room for a future GSO/GRO fast
77/// path and is `None` in phase 1.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct Transmit {
80 /// Destination address (backend for upstream, real client for return).
81 pub dst: SocketAddr,
82 /// GSO segment size hint; `None` in phase 1 (no batching).
83 pub segment_size: Option<usize>,
84 /// Owned datagram bytes, PPv2-prefixed in place when applicable.
85 pub payload: Vec<u8>,
86}
87
88/// The backend set the manager selects from, borrowed from the embedder at
89/// the one point selection happens.
90///
91/// Question 6 of [#1340](https://github.com/sozu-proxy/sozu/issues/1340) puts
92/// routing inside the core for H2, and leaving UDP doing it the other way
93/// would give the repository two architectures and no rule. So selection moves
94/// in here too, and this is how the backend set reaches it without the core
95/// holding a `BackendMap`.
96///
97/// The mechanism is Question 12's, applied again rather than reinvented: a
98/// view in with a defined staleness, a result out. The staleness is **one
99/// admission** — the view is borrowed for the `ManagerInput::ClientDatagram`
100/// that consumes it and not retained.
101///
102/// `&mut self` because selection advances load-balancer state: a round-robin
103/// cursor, an HRW/Maglev lookup, and the availability latch
104/// `BackendMap::record_cluster_availability` maintains. A simulator can
105/// implement this over any backend set it likes, which is what makes the
106/// selection reproducible and is the whole determinism argument for moving it
107/// in.
108pub trait BackendSource {
109 /// Pick a backend for `cluster`, optionally pinned by affinity `key` so a
110 /// client flow stays on one backend under HRW / Maglev. `None` means the
111 /// cluster has no backend that can serve, which is the caller's cue to
112 /// abort the flow rather than park it.
113 ///
114 /// `now` is the instant of the admission, which the embedder's selection
115 /// judges backoff windows and connection-time decay against (#1684).
116 fn select(
117 &mut self,
118 cluster: &str,
119 key: Option<u64>,
120 now: Instant,
121 ) -> Option<(BackendId, SocketAddr)>;
122}
123
124/// Inputs the shell feeds into the manager. Borrows the recv buffer; the core
125/// copies into an owned `Vec<u8>` only on admission.
126///
127/// `Debug` is hand-written rather than derived because
128/// [`ManagerInput::ClientDatagram`] carries a `&mut dyn BackendSource`, which
129/// has no `Debug` bound and should not gain one for a diagnostic. The mux's
130/// `Position` renders itself the same way and for the same reason: the handle
131/// is elided, everything else is shown.
132pub enum ManagerInput<'a> {
133 /// A datagram from a client. Admitted into an existing/new flow or dropped.
134 ///
135 /// Carries the backend view because this is the **only** input that
136 /// selects: admission resolves a backend before the flow exists, so the
137 /// view arrives with the datagram that needs it rather than on every
138 /// entry point. Three of the four inputs cannot select, and a signature
139 /// that demanded a backend set from all of them would say otherwise.
140 ClientDatagram {
141 /// Real (pre-NAT) client source address.
142 src: SocketAddr,
143 /// Borrowed datagram bytes.
144 payload: &'a [u8],
145 /// The backend set to select from, for this admission only.
146 backends: &'a mut dyn BackendSource,
147 },
148 /// A datagram from a backend, tagged by the shell with the owning flow
149 /// (`upstream_token -> FlowId`). Drives the symmetric NAT return path.
150 BackendDatagram {
151 /// Flow that owns the connected upstream socket this arrived on.
152 flow: FlowId,
153 /// Borrowed datagram bytes.
154 payload: &'a [u8],
155 },
156 /// A control-plane / health event (add/remove backend, LB algo, timeouts,
157 /// per-cluster knobs, drain). Never allocates a flow.
158 Config(ConfigEvent),
159}
160
161impl std::fmt::Debug for ManagerInput<'_> {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 match self {
164 Self::ClientDatagram { src, payload, .. } => f
165 .debug_struct("ClientDatagram")
166 .field("src", src)
167 .field("payload_len", &payload.len())
168 .finish_non_exhaustive(),
169 Self::BackendDatagram { flow, payload } => f
170 .debug_struct("BackendDatagram")
171 .field("flow", flow)
172 .field("payload_len", &payload.len())
173 .finish(),
174 Self::Config(event) => f.debug_tuple("Config").field(event).finish(),
175 }
176 }
177}
178
179/// Outputs the manager emits; the shell drains them via `poll_output` until
180/// `None`. The shell owns the actual syscalls and timer wheel.
181#[derive(Clone, Debug, PartialEq, Eq)]
182pub enum Output {
183 /// The shell should `connect()` a fresh upstream socket for this flow and
184 /// register `upstream_token -> flow` for NAT return demux.
185 OpenUpstream {
186 /// Flow that owns the upstream socket.
187 flow: FlowId,
188 /// Backend address to connect to.
189 backend: SocketAddr,
190 },
191 /// Write an owned datagram to the backend (PPv2-prefixed when applicable).
192 SendToBackend(Transmit),
193 /// Write an owned datagram back to the real client (symmetric NAT return).
194 SendToClient(Transmit),
195 /// Re-arm the single manager-wide timer at this absolute deadline. The
196 /// shell owns the wheel; the core only ever requests one deadline.
197 ArmTimer(std::time::Instant),
198 /// A metric event for the shell to translate into `udp.*` counters/gauges.
199 Metric(MetricEvent),
200 /// Tear down a flow (idle / responses-reached / requests-reached / drain).
201 /// The shell closes the upstream socket, frees the slab slot, and
202 /// decrements `udp.active_flows`.
203 CloseFlow(FlowId),
204 /// A datagram was dropped before allocating any flow/buffer/socket
205 /// ("silence is a virtue"). Carries the reason for metrics/logs.
206 Drop(DropReason),
207}
208
209/// Why a datagram was dropped. Maps onto `udp.datagrams.dropped` by-reason.
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211pub enum DropReason {
212 /// Datagram failed validation (empty, or the extractor rejected it).
213 Invalid,
214 /// Datagram exceeded the configured `max_rx_datagram_size` (truncation /
215 /// `MSG_TRUNC` surrogate at this layer).
216 Truncated,
217 /// No cluster/backend is configured for the listener.
218 NoBackend,
219 /// Flow-table cap (`max_flows`) reached — shed the new flow, protect the
220 /// existing ones.
221 Shed,
222 /// A backend datagram referenced an unknown / already-closed flow.
223 UnknownFlow,
224}
225
226/// Metric events the core asks the shell to record. The shell owns the
227/// `incr!`/`count!`/`gauge!`/`time!` macros (`lib/src/metrics/`).
228///
229/// `protocol::mux::h2`'s `MetricEvent` is a different type with the same name,
230/// and deliberately so: it carries the H2 core's own vocabulary and shares no
231/// variant with this one. Converging them would put H2 connection gauges
232/// inside [`Output::Metric`], which every consumer of this enum would then
233/// have to match on. The shared name is the same idiom
234/// [`crate::protocol::tcp_preread::Output`] and [`Output`] already use.
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
236pub enum MetricEvent {
237 /// A new flow was admitted (`udp.flows.created`, `udp.active_flows += 1`).
238 FlowCreated,
239 /// A flow was torn down by idle/teardown/drain (`udp.flows.evicted`,
240 /// `udp.active_flows -= 1`).
241 FlowEvicted,
242 /// A new flow was shed at the cap (`udp.flows.shed`).
243 FlowShed,
244 /// A new flow was shed because its source address or subnet already holds
245 /// the cluster's per-source flow limit (`udp.flows.shed` and
246 /// `udp.flows.shed.source_limit`).
247 FlowShedSourceLimit,
248 /// A client→backend datagram was forwarded (`udp.datagrams.in`,
249 /// `udp.bytes.in`). Carries the *payload* byte count (excludes any PPv2
250 /// prefix the core adds).
251 DatagramIn(usize),
252 /// A backend→client datagram was returned (`udp.datagrams.out`,
253 /// `udp.bytes.out`).
254 DatagramOut(usize),
255 /// A datagram was dropped, by reason (`udp.datagrams.dropped`).
256 DatagramDropped(DropReason),
257}
258
259/// Per-cluster knobs the shell binds to a listener's flows. Mirrors the
260/// `UdpClusterConfig` proto but stays in pure-core terms. Defaults match the
261/// proto defaults.
262#[derive(Clone, Debug, PartialEq, Eq)]
263pub struct ClusterConfig {
264 /// Cluster the listener routes to.
265 pub cluster: ClusterId,
266 /// Hash `src_ip + src_port` (true) vs `src_ip` only (false) into the
267 /// backend-selection affinity key. Maps the `UdpAffinityKey` proto enum.
268 /// Flows are always keyed on the full source address ([`FlowKey`]).
269 pub affinity_with_port: bool,
270 /// Expected replies per flow before close. `0` = unlimited (DNS = 1).
271 pub responses: u32,
272 /// Max client datagrams per flow before close. `0` = unlimited.
273 pub requests: u32,
274 /// Idle timeout (client direction). Resets on every datagram.
275 pub front_timeout: Duration,
276 /// Idle timeout (upstream direction). Resets on every reply.
277 pub back_timeout: Duration,
278 /// Prepend a PPv2 DGRAM header to upstream datagrams.
279 pub send_proxy_protocol: bool,
280 /// PPv2 on every datagram (true) vs first-datagram-only (false, default).
281 pub proxy_protocol_every_datagram: bool,
282 /// Concurrent flows one source IP may hold on this cluster, per listener.
283 /// `0` = unlimited. Resolved by the shell from the cluster's own
284 /// `max_connections_per_ip`; the global default is not inherited, so an
285 /// absent value is unlimited.
286 pub max_flows_per_ip: u64,
287 /// Concurrent flows one source subnet may hold on this cluster, per
288 /// listener. `0` = unlimited. Resolved from `max_connections_per_subnet`
289 /// the same way; the subnet is the source IP masked to the two prefixes
290 /// below.
291 pub max_flows_per_subnet: u64,
292 /// IPv4 prefix length, in bits, of a subnet for `max_flows_per_subnet`.
293 pub subnet_ipv4_prefix: u32,
294 /// IPv6 prefix length, in bits, of a subnet for `max_flows_per_subnet`.
295 pub subnet_ipv6_prefix: u32,
296}
297
298impl Default for ClusterConfig {
299 fn default() -> Self {
300 ClusterConfig {
301 cluster: ClusterId::from(""),
302 affinity_with_port: false,
303 responses: 0,
304 requests: 0,
305 front_timeout: Duration::from_secs(30),
306 back_timeout: Duration::from_secs(30),
307 send_proxy_protocol: false,
308 proxy_protocol_every_datagram: false,
309 max_flows_per_ip: 0,
310 max_flows_per_subnet: 0,
311 subnet_ipv4_prefix: sozu_command::config::DEFAULT_SUBNET_IPV4_PREFIX,
312 subnet_ipv6_prefix: sozu_command::config::DEFAULT_SUBNET_IPV6_PREFIX,
313 }
314 }
315}
316
317/// Control-plane events the shell feeds the manager via
318/// [`ManagerInput::Config`]. Additive; an unknown event must never panic.
319#[derive(Clone, Debug, PartialEq, Eq)]
320pub enum ConfigEvent {
321 /// Replace the listener's cluster routing + per-cluster knobs. Applies to
322 /// new flows; existing flows keep their captured config (stable affinity).
323 SetCluster(ClusterConfig),
324 /// Update the flow-table cap. Shrinking does not evict existing flows; it
325 /// only sheds future ones.
326 SetMaxFlows(usize),
327 /// Update the maximum accepted rx datagram size.
328 SetMaxRxDatagramSize(usize),
329 /// Begin draining: admit no new flows; let existing ones reach teardown.
330 Drain,
331}
332
333/// Logging prefix for the pure UDP core (tag `UDP`). Mirrors
334/// `protocol/mux/mod.rs`'s `log_context!`. The core has no socket/ULID of its
335/// own, so the manager-level prefix carries its admission counters; honours the
336/// colored flag via [`ansi_palette`](sozu_command::logging::ansi_palette).
337#[allow(unused_macros)]
338macro_rules! log_context {
339 ($self:expr) => {{
340 let (open, reset, grey, gray, white) = sozu_command::logging::ansi_palette();
341 format!(
342 "[- - - -]\t{open}UDP{reset}\t{grey}Manager{reset}({gray}flows{reset}={white}{flows}{reset}, {gray}max_flows{reset}={white}{max_flows}{reset}, {gray}draining{reset}={white}{draining}{reset})\t >>>",
343 open = open,
344 reset = reset,
345 grey = grey,
346 gray = gray,
347 white = white,
348 flows = $self.flow_count(),
349 max_flows = $self.max_flows(),
350 draining = $self.is_draining(),
351 )
352 }};
353}
354
355/// Per-flow logging prefix (tag `UDP-FLOW`). Renders the canonical
356/// `[session req cluster backend]`-style bracket reduced to the flow's stable
357/// id + client + backend, so flow lines stay filterable.
358#[allow(unused_macros)]
359macro_rules! log_context_lite {
360 ($flow_id:expr, $flow:expr) => {{
361 let (open, reset, grey, gray, white) = sozu_command::logging::ansi_palette();
362 format!(
363 "[- - - -]\t{open}UDP-FLOW{reset}\t{grey}Flow{reset}({gray}id{reset}={white}{id}{reset}, {gray}client{reset}={white}{client}{reset}, {gray}backend{reset}={white}{backend:?}{reset})\t >>>",
364 open = open,
365 reset = reset,
366 grey = grey,
367 gray = gray,
368 white = white,
369 id = $flow_id,
370 client = $flow.client,
371 backend = $flow.backend_addr,
372 )
373 }};
374}
375
376#[allow(unused_imports)]
377pub(crate) use {log_context, log_context_lite};