Skip to main content

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};