Skip to main content

sozu_command_lib/
config.rs

1//! # Sōzu's configuration
2//!
3//! This module is responsible for parsing the `config.toml` provided by the flag `--config`
4//! when starting Sōzu.
5//!
6//! Here is the workflow for generating a working config:
7//!
8//! ```text
9//!     config.toml   ->   FileConfig    ->  ConfigBuilder   ->  Config
10//! ```
11//!
12//! `config.toml` is parsed to `FileConfig`, a structure that itself contains a lot of substructures
13//! whose names start with `File-` and end with `-Config`, like `FileHttpFrontendConfig` for instance.
14//!
15//! The instance of `FileConfig` is then passed to a `ConfigBuilder` that populates a final `Config`
16//! with listeners and clusters.
17//!
18//! To illustrate:
19//!
20//! ```no_run
21//! use sozu_command_lib::config::{FileConfig, ConfigBuilder};
22//!
23//! let file_config = FileConfig::load_from_path("../config.toml")
24//!     .expect("Could not load config.toml");
25//!
26//! let config = ConfigBuilder::new(file_config, "../assets/config.toml")
27//!     .into_config()
28//!     .expect("Could not build config");
29//! ```
30//!
31//! Note that the path to `config.toml` is used twice: the first time, to parse the file,
32//! the second time, to keep the path in the config for later use.
33//!
34//! However, there is a simpler way that combines all this:
35//!
36//! ```no_run
37//! use sozu_command_lib::config::Config;
38//!
39//! let config = Config::load_from_path("../assets/config.toml")
40//!     .expect("Could not build config from the path");
41//! ```
42//!
43//! ## How values are chosen
44//!
45//! Values are chosen in this order of priority:
46//!
47//! 1. values defined in a section of the TOML file, for instance, timeouts for a specific listener
48//! 2. values defined globally in the TOML file, like timeouts or buffer size
49//! 3. if a variable has not been set in the TOML file, it will be set to a default defined here
50use std::{
51    collections::{BTreeMap, HashMap, HashSet},
52    env, fmt,
53    fs::{File, create_dir_all, metadata},
54    io::{ErrorKind, Read},
55    net::SocketAddr,
56    ops::Range,
57    path::PathBuf,
58};
59
60use crate::{
61    ObjectKind,
62    certificate::split_certificate_chain,
63    listener_key::{InterfaceError, ListenerKey, validate_listener_interface},
64    logging::AccessLogFormat,
65    proto::command::{
66        ActivateListener, AddBackend, AddCertificate, CertificateAndKey, ClientAuthMode, Cluster,
67        CustomHttpAnswers, ForwardedHeaders, Header, HeaderPosition, HealthCheckConfig,
68        HealthCheckMode, HstsConfig, HttpListenerConfig, HttpStatusRange, HttpsListenerConfig,
69        ListenerType, LoadBalancingAlgorithms, LoadBalancingParams, LoadMetric, MetricDetail,
70        MetricsConfiguration, PathRule, ProtobufAccessLogFormat, ProxyProtocolConfig,
71        RedirectPolicy, RedirectScheme, Request, RequestHttpFrontend, RequestTcpFrontend,
72        RequestUdpFrontend, RulePosition, ServerConfig, ServerMetricsConfig, ShardMode,
73        SocketAddress, TcpListenerConfig, TlsVersion, UdpAffinityKey, UdpClusterConfig,
74        UdpHealthConfig, UdpHealthMode, UdpListenerConfig, WorkerRequest, request::RequestType,
75    },
76};
77
78/// Authoritative list of default cipher suites for all rustls-based TLS providers.
79///
80/// These use rustls naming conventions and are supported by all three crypto providers
81/// (ring, aws-lc-rs, rustls-openssl). Order follows ANSSI recommendations: AES-256
82/// preferred over AES-128, ECDSA preferred over RSA, TLS 1.3 preferred over TLS 1.2.
83///
84/// See the [documentation](https://docs.rs/rustls/latest/rustls/static.ALL_CIPHER_SUITES.html)
85pub const DEFAULT_CIPHER_LIST: [&str; 9] = [
86    // TLS 1.3 cipher suites
87    "TLS13_AES_256_GCM_SHA384",
88    "TLS13_AES_128_GCM_SHA256",
89    "TLS13_CHACHA20_POLY1305_SHA256",
90    // TLS 1.2 cipher suites
91    "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
92    "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
93    "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
94    "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
95    "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
96    "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
97];
98
99pub const DEFAULT_SIGNATURE_ALGORITHMS: [&str; 9] = [
100    "ECDSA+SHA256",
101    "ECDSA+SHA384",
102    "ECDSA+SHA512",
103    "RSA+SHA256",
104    "RSA+SHA384",
105    "RSA+SHA512",
106    "RSA-PSS+SHA256",
107    "RSA-PSS+SHA384",
108    "RSA-PSS+SHA512",
109];
110
111pub const DEFAULT_GROUPS_LIST: [&str; 4] = ["X25519MLKEM768", "x25519", "P-256", "P-384"];
112
113/// Default ALPN protocols advertised by HTTPS listeners.
114/// Both HTTP/2 and HTTP/1.1 are enabled, allowing clients to negotiate either.
115pub const DEFAULT_ALPN_PROTOCOLS: [&str; 2] = ["h2", "http/1.1"];
116
117/// maximum time of inactivity for a frontend socket (60 seconds)
118pub const DEFAULT_FRONT_TIMEOUT: u32 = 60;
119
120/// permission bits given to the unix command socket when `command_socket_mode`
121/// is absent: owner read/write only, the mode Sōzu has always used
122pub const DEFAULT_COMMAND_SOCKET_MODE: u32 = 0o600;
123
124/// every permission bit `command_socket_mode` may set. setuid, setgid and the
125/// sticky bit mean nothing on a socket and are refused.
126pub const MAX_COMMAND_SOCKET_MODE: u32 = 0o777;
127
128/// maximum time of inactivity for a backend socket (30 seconds)
129pub const DEFAULT_BACK_TIMEOUT: u32 = 30;
130
131/// maximum time to connect to a backend server (3 seconds)
132pub const DEFAULT_CONNECT_TIMEOUT: u32 = 3;
133
134/// maximum time allowed to receive enough bytes of the TLS ClientHello to
135/// read the SNI extension on a TCP listener (5 seconds). Only relevant when
136/// at least one SNI-scoped `TcpFrontendConfig` targets the listener. Must
137/// match the proto default on `TcpListenerConfig.sni_preread_timeout`.
138pub const DEFAULT_SNI_PREREAD_TIMEOUT: u32 = 5;
139
140/// maximum number of bytes buffered while prereading the TLS ClientHello
141/// looking for the SNI extension on a TCP listener (16 KB — matches the
142/// H2 frame ceiling used elsewhere in this file). Only relevant when at
143/// least one SNI-scoped `TcpFrontendConfig` targets the listener; clamped by
144/// the global `buffer_size` (see `ConfigError::SniPrereadMaxBytesExceedsBufferSize`).
145/// Must match the proto default on
146/// `TcpListenerConfig.sni_preread_max_bytes`.
147pub const DEFAULT_SNI_PREREAD_MAX_BYTES: u32 = 16384;
148
149/// minimum allowed `sni_preread_max_bytes` on a TCP listener targeted by an
150/// SNI frontend (5 bytes — a full TLS record header: 1-byte `ContentType` +
151/// 2-byte `ProtocolVersion` + 2-byte length, RFC 8446 §5.1). `0` (and every
152/// value below this floor) makes the preread shell issue reads that can
153/// never accumulate enough bytes to parse even the outer record framing,
154/// spinning until the event-loop iteration guard (`MAX_LOOP_ITERATIONS`)
155/// trips instead of ever reaching a routing decision. See
156/// `ConfigError::SniPrereadMaxBytesTooSmall`.
157pub const MIN_SNI_PREREAD_MAX_BYTES: u32 = 5;
158
159/// maximum time to receive a request since the connection started (10 seconds)
160pub const DEFAULT_REQUEST_TIMEOUT: u32 = 10;
161
162/// client/upstream flow idle timeout for a UDP listener (30 seconds)
163pub const DEFAULT_UDP_FRONT_TIMEOUT: u32 = 30;
164
165/// upstream flow idle timeout for a UDP listener (30 seconds)
166pub const DEFAULT_UDP_BACK_TIMEOUT: u32 = 30;
167
168/// maximum received datagram size for a UDP listener, in bytes (1500 = a
169/// typical Ethernet MTU). Capped at the effective `buffer_size` at runtime.
170pub const DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE: u32 = 1500;
171
172/// maximum number of concurrent UDP flows per listener. `0` selects the
173/// runtime auto policy (~70% of the soft `RLIMIT_NOFILE`).
174pub const DEFAULT_UDP_MAX_FLOWS: u32 = 0;
175
176/// maximum time to wait for a worker to respond, until it is deemed NotAnswering (10 seconds)
177pub const DEFAULT_WORKER_TIMEOUT: u32 = 10;
178
179/// a name applied to sticky sessions ("SOZUBALANCEID")
180pub const DEFAULT_STICKY_NAME: &str = "SOZUBALANCEID";
181
182/// Interval between checking for zombie sessions, (30 minutes)
183pub const DEFAULT_ZOMBIE_CHECK_INTERVAL: u32 = 1_800;
184
185/// timeout to accept connection events in the accept queue (60 seconds)
186pub const DEFAULT_ACCEPT_QUEUE_TIMEOUT: u32 = 60;
187
188/// Default `Strict-Transport-Security: max-age` value (1 year, 31_536_000
189/// seconds) substituted at config-load when an `[hsts]` block sets
190/// `enabled = true` but omits `max_age`. Matches the HSTS preload list
191/// minimum (<https://hstspreload.org/>) and the Caddy / Nginx community
192/// recommendation. Operators can override with any `u32`; `max_age = 0`
193/// is the RFC 6797 §11.4 kill switch and is allowed silently.
194pub const DEFAULT_HSTS_MAX_AGE: u32 = 31_536_000;
195
196/// whether to evict least-recently-active sessions when the accept queue is
197/// saturated (false). Defaults to false because during a DDoS the existing
198/// connections are more likely to be legitimate clients than the queued ones;
199/// evicting them would serve the attacker. Enable when overload is dominated
200/// by normal traffic spikes rather than attacks.
201pub const DEFAULT_EVICT_ON_QUEUE_FULL: bool = false;
202
203/// number of workers, i.e. Sōzu processes that scale horizontally (2)
204pub const DEFAULT_WORKER_COUNT: u16 = 2;
205
206/// wether a worker is automatically restarted when it crashes (true)
207pub const DEFAULT_WORKER_AUTOMATIC_RESTART: bool = true;
208
209/// wether to save the state automatically (false)
210pub const DEFAULT_AUTOMATIC_STATE_SAVE: bool = false;
211
212/// minimum number of buffers (1)
213pub const DEFAULT_MIN_BUFFERS: u64 = 1;
214
215/// maximum number of buffers (1 000)
216pub const DEFAULT_MAX_BUFFERS: u64 = 1_000;
217
218/// size of the buffers, in bytes (16 KB)
219pub const DEFAULT_BUFFER_SIZE: u64 = 16_393;
220
221/// minimum buffer size required when any HTTPS listener advertises H2 ALPN.
222///
223/// RFC 9113 §6.5.2 caps `SETTINGS_MAX_FRAME_SIZE` at 16 384 bytes by default;
224/// the on-wire H2 frame header is a fixed 9 bytes (§4.1), so the kawa storage
225/// must be able to hold 16 384 + 9 = 16 393 bytes before forwarding. A smaller
226/// `buffer_size` causes the H2 mux to deadlock on full-size frames (no panic,
227/// no obvious log) until the session timeout fires. Validated at config-load
228/// time in `ConfigBuilder::into_config` so a typo in TOML is rejected at boot,
229/// not discovered under traffic.
230pub const H2_MIN_BUFFER_SIZE: u64 = 16_393;
231
232/// maximum number of simultaneous connections (10 000)
233pub const DEFAULT_MAX_CONNECTIONS: usize = 10_000;
234
235/// size of the buffer for the channels, in bytes. Must be bigger than the size of the data received. (1 MB)
236pub const DEFAULT_COMMAND_BUFFER_SIZE: u64 = 1_000_000;
237
238/// maximum size of the buffer for the channels, in bytes. (2 MB)
239pub const DEFAULT_MAX_COMMAND_BUFFER_SIZE: u64 = 2_000_000;
240
241/// wether to avoid register cluster metrics in the local drain
242pub const DEFAULT_DISABLE_CLUSTER_METRICS: bool = false;
243
244pub const MAX_LOOP_ITERATIONS: usize = 100000;
245
246/// Number of TLS 1.3 tickets to send to a client when establishing a connection.
247/// The tickets allow the client to resume a session. This protects the client
248/// agains session tracking. Increases the number of getrandom syscalls,
249/// with little influence on performance. Defaults to 4.
250pub const DEFAULT_SEND_TLS_13_TICKETS: u64 = 4;
251
252/// for both logs and access logs
253pub const DEFAULT_LOG_TARGET: &str = "stdout";
254
255/// Default per-(cluster, source-IP) connection limit. `0` means unlimited.
256/// Counts are kept per `(cluster_id, source_ip)` so two clusters never
257/// share a counter even from the same IP. Per-cluster overrides on the
258/// `Cluster` message take precedence.
259pub const DEFAULT_MAX_CONNECTIONS_PER_IP: u64 = 0;
260
261/// Default `Retry-After` header value (seconds) on HTTP 429 responses
262/// emitted when a per-(cluster, source-IP) connection limit is hit. `0`
263/// omits the header — `Retry-After: 0` invites an immediate retry that
264/// defeats the limit. TCP rejections do not emit this value (no HTTP
265/// envelope), but the field is accepted for symmetry.
266pub const DEFAULT_RETRY_AFTER: u32 = 60;
267
268/// Default per-(cluster, source-SUBNET) connection limit. `0` means
269/// unlimited, which disables the subnet limiter entirely and leaves the
270/// pre-existing per-IP behaviour strictly unchanged. This is a SECOND,
271/// independent counter beside [`DEFAULT_MAX_CONNECTIONS_PER_IP`], not a
272/// replacement: the per-IP cap serves the API-gateway case, the
273/// per-subnet cap the denial-of-service case where an attacker holding a
274/// routed prefix takes a fresh address per connection
275/// (sozu-proxy/sozu#1270). Both gates are consulted and both must admit.
276pub const DEFAULT_MAX_CONNECTIONS_PER_SUBNET: u64 = 0;
277
278/// Default backend connection attempt budget of a request: how many backend
279/// connections it may try, the first included, before it is answered `503`
280/// (HTTP) or closed (TCP). Every failed connection counts — refused, timed
281/// out on `connect_timeout`, or reporting a socket error. Replaces the
282/// hard-coded `CONN_RETRIES = 3` of `sozu-lib`; the default rose to 5 with
283/// sozu-proxy/sozu#1800, so a request survives more dead backends in a row.
284pub const DEFAULT_MAX_CONNECTION_ATTEMPTS: u32 = 5;
285
286/// Inclusive upper bound of `max_connection_attempts`, global or per
287/// cluster: the attempt counters are `u8`.
288pub const MAX_CONNECTION_ATTEMPTS_UPPER_BOUND: u32 = u8::MAX as u32;
289
290/// Validate a `max_connection_attempts` value, global or per cluster: at least
291/// one attempt, at most [`MAX_CONNECTION_ATTEMPTS_UPPER_BOUND`]. Called at
292/// config load, on every `AddCluster` and on `SetMaxConnectionAttempts`.
293pub fn validate_max_connection_attempts(attempts: u32) -> Result<(), &'static str> {
294    if (1..=MAX_CONNECTION_ATTEMPTS_UPPER_BOUND).contains(&attempts) {
295        Ok(())
296    } else {
297        Err("max_connection_attempts must lie in 1..=255")
298    }
299}
300
301/// Default IPv4 prefix length (bits) used to derive the subnet key. 32
302/// masks nothing, so enabling only `max_connections_per_subnet` yields a
303/// per-address counter rather than a surprising aggregation. `/24` is the
304/// recommended starting point; see `doc/configure.md`.
305pub const DEFAULT_SUBNET_IPV4_PREFIX: u32 = 32;
306
307/// Default IPv6 prefix length (bits) used to derive the subnet key. 128
308/// masks nothing. `/56` is the recommended starting point; see
309/// `doc/configure.md`.
310pub const DEFAULT_SUBNET_IPV6_PREFIX: u32 = 128;
311
312/// Inclusive upper bound for [`DEFAULT_SUBNET_IPV4_PREFIX`] and the
313/// `subnet_ipv4_prefix` config key. A value above this is a config error,
314/// never silently clamped.
315pub const MAX_SUBNET_IPV4_PREFIX: u32 = 32;
316
317/// Inclusive upper bound for [`DEFAULT_SUBNET_IPV6_PREFIX`] and the
318/// `subnet_ipv6_prefix` config key. A value above this is a config error,
319/// never silently clamped.
320pub const MAX_SUBNET_IPV6_PREFIX: u32 = 128;
321
322#[derive(Debug)]
323pub enum IncompatibilityKind {
324    PublicAddress,
325    ProxyProtocol,
326}
327
328#[derive(Debug)]
329pub enum MissingKind {
330    Field(String),
331    Protocol,
332    SavedState,
333}
334
335#[derive(thiserror::Error, Debug)]
336pub enum ConfigError {
337    #[error("env path not found: {0}")]
338    Env(String),
339    #[error("Could not open file {path_to_open}: {io_error}")]
340    FileOpen {
341        path_to_open: String,
342        io_error: std::io::Error,
343    },
344    #[error("Could not read file {path_to_read}: {io_error}")]
345    FileRead {
346        path_to_read: String,
347        io_error: std::io::Error,
348    },
349    #[error(
350        "the field {kind:?} of {object:?} with id or address {id} is incompatible with the rest of the options"
351    )]
352    Incompatible {
353        kind: IncompatibilityKind,
354        object: ObjectKind,
355        id: String,
356    },
357    #[error("Invalid '{0}' field for a TCP frontend")]
358    InvalidFrontendConfig(String),
359    #[error("invalid path {0:?}")]
360    InvalidPath(PathBuf),
361    #[error("listening address {0:?} is already used in the configuration")]
362    ListenerAddressAlreadyInUse(SocketAddr),
363    #[error("listener {0} is already declared in the configuration")]
364    ListenerAlreadyDeclared(ListenerKey),
365    #[error(
366        "listeners sharing the address {0} on different interfaces must all use the same protocol"
367    )]
368    ListenerProtocolMismatchOnAddress(SocketAddr),
369    #[error("listener {address}: {error}")]
370    ListenerInterface {
371        address: SocketAddr,
372        error: InterfaceError,
373    },
374    #[error("missing {0:?}")]
375    Missing(MissingKind),
376    #[error("could not get parent directory for file {0}")]
377    NoFileParent(String),
378    #[error("Could not get the path of the saved state")]
379    SaveStatePath(String),
380    #[error("Can not determine path to sozu socket: {0}")]
381    SocketPathError(String),
382    #[error("toml decoding error: {0}")]
383    DeserializeToml(String),
384    #[error("Can not set this frontend on a {0:?} listener")]
385    WrongFrontendProtocol(ListenerProtocol),
386    #[error("Can not build a {expected:?} listener from a {found:?} config")]
387    WrongListenerProtocol {
388        expected: ListenerProtocol,
389        found: Option<ListenerProtocol>,
390    },
391    #[error("Invalid ALPN protocol '{0}'. Valid values: \"h2\", \"http/1.1\"")]
392    InvalidAlpnProtocol(String),
393    /// A cluster's `affinity_header` / `affinity_cookie` breaks
394    /// [`validate_affinity_key`], or is set on a TCP cluster.
395    #[error("cluster {cluster_id}: {reason}")]
396    InvalidAffinityKey {
397        cluster_id: String,
398        reason: &'static str,
399    },
400    /// A cluster's shuffle-sharding knobs break [`validate_shuffle_sharding`].
401    #[error("cluster {cluster_id}: {reason}")]
402    InvalidShuffleSharding {
403        cluster_id: String,
404        reason: &'static str,
405    },
406    /// A `max_connection_attempts`, global (`cluster_id` is `None`) or of a
407    /// cluster, breaks [`validate_max_connection_attempts`].
408    #[error("max_connection_attempts = {value}{}: {reason}", cluster_id.as_deref().map(|id| format!(" in cluster {id}")).unwrap_or_default())]
409    InvalidMaxConnectionAttempts {
410        cluster_id: Option<String>,
411        value: u32,
412        reason: &'static str,
413    },
414    /// A cluster's `health_check` block has an unparsable `accepted_statuses`
415    /// entry or breaks [`validate_health_check_config`].
416    #[error("cluster {cluster_id}: {reason}")]
417    InvalidHealthCheck {
418        cluster_id: String,
419        reason: &'static str,
420    },
421    /// `subnet_ipv4_prefix` / `subnet_ipv6_prefix` is outside the bit
422    /// width of its address family. Rejected rather than clamped: a
423    /// silently narrowed mask would enforce a different policy than the
424    /// one written in the file, and the operator would have no way to
425    /// tell from the config which one is in force.
426    #[error(
427        "{key} = {value} is out of range: a {family} prefix length must be between 0 and \
428         {maximum} bits inclusive."
429    )]
430    InvalidSubnetPrefix {
431        key: &'static str,
432        family: &'static str,
433        value: u32,
434        maximum: u32,
435    },
436    /// `command_socket_mode` is not an octal permission string within
437    /// [`MAX_COMMAND_SOCKET_MODE`]. See [`parse_command_socket_mode`].
438    #[error("command_socket_mode = {value:?} is invalid: {reason}")]
439    InvalidCommandSocketMode { value: String, reason: &'static str },
440    /// `disable_http11 = true` and `alpn_protocols` containing `"http/1.1"`
441    /// are mutually exclusive: the proxy advertises `http/1.1` to peers,
442    /// then refuses every connection that negotiates
443    /// it. The combination is a self-DoS at handshake time. Either drop
444    /// `http/1.1` from `alpn_protocols` or unset `disable_http11`.
445    #[error(
446        "disable_http11 = true is incompatible with alpn_protocols containing \"http/1.1\" \
447         on listener {address}. The proxy would advertise http/1.1 then refuse every \
448         connection that negotiates it. Drop \"http/1.1\" from alpn_protocols or unset \
449         disable_http11."
450    )]
451    DisableHttp11WithHttp11Alpn { address: String },
452    /// `buffer_size` is below the H2 minimum (16 393 bytes) but at least one
453    /// HTTPS listener advertises `h2` in its ALPN list. The H2 mux requires
454    /// 16 384-byte frame payload + 9-byte header to fit in a single kawa
455    /// buffer; smaller values deadlock streams that carry full-size frames.
456    /// Either raise `buffer_size` to ≥ 16 393 or remove `h2` from the
457    /// affected listeners' `alpn_protocols`.
458    #[error(
459        "buffer_size = {buffer_size} is below the H2 minimum of {minimum} but \
460         {listeners} HTTPS listener(s) advertise H2 ALPN. The H2 mux deadlocks \
461         on full-size frames with smaller buffers. Raise buffer_size to >= {minimum} \
462         or remove \"h2\" from those listeners' alpn_protocols."
463    )]
464    BufferSizeTooSmallForH2 {
465        buffer_size: u64,
466        minimum: u64,
467        listeners: usize,
468    },
469    /// `command_buffer_size` is greater than `max_command_buffer_size`. The command
470    /// channel (`command/src/channel.rs`) allocates its initial buffer at
471    /// `command_buffer_size` and must never let it exceed `max_command_buffer_size` —
472    /// every growth/shrink path on that channel reasons against that ceiling. Rejected
473    /// at load rather than silently clamped, so the operator sees which of the two
474    /// keys to fix instead of the mistake being rewritten out from under them
475    /// (sozu-proxy/sozu#1416).
476    #[error(
477        "command_buffer_size = {command_buffer_size} exceeds max_command_buffer_size = \
478         {max_command_buffer_size}: lower command_buffer_size to <= {max_command_buffer_size} \
479         or raise max_command_buffer_size to >= {command_buffer_size}"
480    )]
481    CommandBufferSizeExceedsMax {
482        command_buffer_size: u64,
483        max_command_buffer_size: u64,
484    },
485    /// An H2 listener knob was set below the minimum the runtime can honour.
486    /// Every flood-detection threshold needs `>= 1`: `check_flood`
487    /// (`lib/src/protocol/mux/h2_flood_detector.rs`) compares
488    /// `count > threshold`, so `0` does not disable the check, it makes the
489    /// first event the counter sees a violation. `h2_stream_shrink_ratio`
490    /// needs `>= 2` for the same reason its runtime `.max(2)` exists.
491    ///
492    /// What that costs depends on which counter the key guards, and the error
493    /// text says so rather than promising the worst case for all of them:
494    ///
495    /// - `h2_max_header_list_size` and `h2_max_header_fields` are *also* the
496    ///   HPACK decode budget `lib/src/protocol/mux/pkawa.rs`'s
497    ///   `decode_headers_with_budget` enforces. At `0` the first header field
498    ///   of every block already exceeds it (name + value + the 32-octet RFC
499    ///   9113 §6.5.2 overhead), so every request on the listener is refused
500    ///   with `ENHANCE_YOUR_CALM`. Immediate and universal. The frame it
501    ///   arrives in depends on the request: a header block that fits one
502    ///   HEADERS frame is refused per stream (`pkawa.rs`'s
503    ///   `Err((EnhanceYourCalm, false))` → `reset_stream`), while one that
504    ///   spans CONTINUATION frames takes the whole connection down —
505    ///   `record_continuation_frame` lifts `accumulated_header_size` above the
506    ///   zero threshold and `check_flood_or_return!`
507    ///   (`lib/src/protocol/mux/h2.rs`) escalates to
508    ///   `GOAWAY(ENHANCE_YOUR_CALM)` before the explicit size test below it.
509    ///   `h2_max_header_table_size` is NOT in this class: `0` only zeroes the
510    ///   HPACK encoder's dynamic table.
511    /// - The per-window and lifetime frame counters only move when a client
512    ///   sends the frame they count. `h2_max_rst_stream_per_window = 0` is
513    ///   harmless to a connection that never sends RST_STREAM — `0 > 0` is
514    ///   false — and fatal to the first one that does.
515    /// - `h2_max_concurrent_streams` and `h2_stream_shrink_ratio` are not
516    ///   flood counters at all: `H2ConnectionConfig::new`
517    ///   (`lib/src/protocol/mux/h2.rs`) promotes an out-of-range value to the
518    ///   floor behind a `warn!`, which is the silent rewrite this error
519    ///   exists to replace.
520    ///
521    /// Rejected at config load rather than silently clamped, so the operator
522    /// sees which key to fix instead of the mistake being rewritten out from
523    /// under them. `UpdateHttp(s)Listener` already refuses the same values via
524    /// `validate_h2_flood_knobs_http`/`_https` (`command/src/state.rs`); this
525    /// is the matching gate on the config-file / `sozu ctl add listener` path
526    /// (sozu-proxy/sozu#1418).
527    #[error(
528        "listener {address}: {key} = {value} is below the minimum of {minimum} the runtime can \
529         honour. Below the minimum the value does not relax the limit, it breaks it: the H2 flood \
530         detector compares count > threshold, so 0 is already exceeded the first time the counter \
531         it guards leaves zero. How far that reaches depends on the key — \
532         h2_max_header_list_size and h2_max_header_fields are also the HPACK decode budget, so 0 \
533         refuses every request on this listener with ENHANCE_YOUR_CALM (as a stream reset, or as \
534         a connection GOAWAY once a header block spans CONTINUATION frames), while a per-window \
535         or lifetime frame counter only trips once a client sends the frame it counts, and \
536         h2_max_concurrent_streams / h2_stream_shrink_ratio are instead promoted to their floor \
537         behind a warning. Raise {key} to >= {minimum}, or remove it to keep the default."
538    )]
539    H2ThresholdBelowMinimum {
540        address: String,
541        key: &'static str,
542        value: u64,
543        minimum: u64,
544    },
545    /// `redirect = "<value>"` on a frontend used a value the parser doesn't
546    /// recognise. Accepted values are `forward`, `permanent`, `unauthorized`
547    /// (case-insensitive).
548    #[error(
549        "invalid redirect policy '{0}'. Valid values: \"forward\", \"permanent\", \"unauthorized\""
550    )]
551    InvalidRedirectPolicy(String),
552    /// `redirect_scheme = "<value>"` on a frontend used a value the parser
553    /// doesn't recognise. Accepted values are `use-same`, `use-http`,
554    /// `use-https` (case-insensitive).
555    #[error(
556        "invalid redirect scheme '{0}'. Valid values: \"use-same\", \"use-http\", \"use-https\""
557    )]
558    InvalidRedirectScheme(String),
559    /// A `[[clusters.<id>.frontends.headers]]` entry carried an unknown
560    /// `position` value. Accepted values are `request`, `response`, `both`
561    /// (case-insensitive).
562    #[error(
563        "invalid header position '{position}' at headers[{index}]. Valid values: \"request\", \"response\", \"both\""
564    )]
565    InvalidHeaderPosition { index: usize, position: String },
566    /// A `[[clusters.<id>.frontends.headers]]` entry contains a forbidden
567    /// byte (NUL, CR, LF, or another C0 control) in its key or value.
568    /// Accepting these would produce HTTP request/response splitting on
569    /// the wire (CWE-113) — the worker's H2 emission path filters them
570    /// at runtime, but the H1 path serialises raw, so we reject at
571    /// config-load time as a defense in depth.
572    #[error(
573        "invalid header bytes in {field} at headers[{index}]: control characters \
574         (NUL / CR / LF / other C0) are forbidden in header keys and values"
575    )]
576    InvalidHeaderBytes { index: usize, field: &'static str },
577    /// An `[hsts]` block populated `max_age`, `include_subdomains`, or
578    /// `preload` but did not set `enabled`. The TOML representation requires
579    /// `enabled` to be present whenever the block is — that single field
580    /// disambiguates "preserve current" / "explicit disable" / "enable" on
581    /// hot-reconfig partial updates.
582    #[error("invalid HSTS config at {0}: `enabled` is required when an [hsts] block is present")]
583    HstsEnabledRequired(String),
584    /// An `[hsts]` block on an HTTP-only listener or frontend. RFC 6797
585    /// §7.2 forbids emitting `Strict-Transport-Security` over plaintext
586    /// HTTP; sozu rejects the configuration at load time so the
587    /// non-conformant policy never ships to a worker.
588    #[error(
589        "invalid HSTS config at {0}: HSTS is only valid on HTTPS listeners and frontends \
590         (RFC 6797 §7.2 forbids the header over plaintext HTTP)"
591    )]
592    HstsOnPlainHttp(String),
593    /// A client-certificate-authentication (mTLS) field was set on a non-HTTPS
594    /// listener. Client auth is a TLS-termination control; HTTP/TCP/UDP
595    /// listeners discard it, so sozu rejects it at load time rather than
596    /// silently starting an unauthenticated listener.
597    #[error(
598        "invalid client auth config at {0}: client_auth / client_ca_certificates / \
599         client_ca_crls are only valid on HTTPS listeners"
600    )]
601    ClientAuthOnNonHttps(String),
602    /// An HTTPS listener names client CA or CRL files but no `client_auth`
603    /// mode. The mode defaults to `none`, which ignores those files, so the
604    /// listener would start without the client authentication they suggest.
605    #[error(
606        "invalid client auth config at {0}: client_ca_certificates / client_ca_crls are set \
607         but client_auth is not; set client_auth to \"optional\" or \"required\", or to \
608         \"none\" to keep them unused"
609    )]
610    ClientAuthModeMissing(String),
611    /// A TCP frontend's `hostname` (mapped to the wire `sni` field) is
612    /// neither an exact hostname nor a single leading `*.` wildcard label
613    /// (sozu-proxy/sozu#1279). Rejects `*.*.example.com`, an embedded `*`
614    /// anywhere but the leading label, an empty label, and any `/` — never
615    /// valid in a hostname, and a leftmost `/.../` label would otherwise be
616    /// inserted into the `pattern_trie` route table as a REGEX segment.
617    #[error(
618        "invalid SNI pattern '{sni}' for a TCP frontend: expected an exact hostname or a \
619         single leading \"*.\" wildcard label (e.g. \"example.com\" or \"*.example.com\"); \
620         '/' and non-leading '*' are rejected"
621    )]
622    InvalidSniPattern { sni: String },
623    /// A TCP frontend's SNI pattern contains non-ASCII characters. On-wire
624    /// SNI is always an ASCII A-label (RFC 6066 §3 / IDNA), so a Unicode
625    /// U-label in the config would load fine but never match any
626    /// ClientHello — a silent routing failure. Rejected loudly until IDNA
627    /// normalization is supported at config-load; the operator must write
628    /// the punycode A-label form instead.
629    #[error(
630        "non-ASCII SNI pattern '{sni}' for a TCP frontend: on-wire SNI is always an ASCII \
631         A-label (RFC 6066), so this pattern would never match a ClientHello. Write the \
632         punycode A-label form instead (e.g. \"xn--mnchen-3ya.example\" for \
633         \"münchen.example\")"
634    )]
635    NonAsciiSniPattern { sni: String },
636    /// A TCP frontend set `alpn` but left `hostname` (mapped to the wire
637    /// `sni` field) unset. An ALPN matcher only ever gets consulted from
638    /// within the SNI-scoped preread route table; a frontend with no `sni`
639    /// installs the worker's raw no-SNI catch-all path instead
640    /// (`TcpListener::cluster_id`), which never looks at `alpn` at all --
641    /// the configured protocol list would silently never be enforced.
642    /// Reject at config-load rather than mis-routing every connection
643    /// through unconditionally.
644    #[error(
645        "TCP frontend {address} sets alpn but no hostname (sni): alpn only matches within an \
646         SNI-scoped preread, so a frontend without hostname would silently ignore its alpn list. \
647         Set hostname or drop alpn."
648    )]
649    AlpnWithoutSni { address: SocketAddr },
650    /// Two TCP frontends on the same `(address, sni)` advertise an
651    /// overlapping ALPN protocol. Routing on a listener must be
652    /// deterministic: if both frontends could match the same ClientHello,
653    /// which cluster receives the connection would depend on iteration
654    /// order rather than configuration.
655    #[error(
656        "TCP frontends on {address} with sni {sni:?} both match ALPN protocol '{protocol}': \
657         ALPN matchers for the same (address, sni) must not overlap"
658    )]
659    TcpFrontendAlpnOverlap {
660        address: SocketAddr,
661        sni: Option<String>,
662        protocol: String,
663    },
664    /// More than one TCP frontend on the same `(address, sni)` left `alpn`
665    /// empty. An empty `alpn` is the catch-all match for that `sni`; two
666    /// catch-alls on the same `(address, sni)` are as ambiguous as two
667    /// frontends sharing an explicit protocol.
668    #[error(
669        "more than one TCP frontend on {address} with sni {sni:?} leaves alpn empty (the \
670         catch-all match): at most one frontend per (address, sni) may omit alpn"
671    )]
672    TcpFrontendMultipleAlpnCatchAll {
673        address: SocketAddr,
674        sni: Option<String>,
675    },
676    /// A TCP listener address is targeted by both a no-SNI frontend and at
677    /// least one SNI-scoped frontend. An SNI-enabled listener prereads the
678    /// ClientHello before choosing a backend; a raw-TCP fallback frontend
679    /// with no SNI to match against would be unreachable for any client
680    /// that doesn't send SNI, and ambiguous for any that does, so the two
681    /// shapes cannot share a listener.
682    #[error(
683        "TCP listener {address} is targeted by both a no-SNI frontend and at least one \
684         SNI-scoped frontend: an SNI-enabled listener must not also have a raw-TCP fallback \
685         frontend on the same address"
686    )]
687    TcpListenerMixesSniAndNoSni { address: SocketAddr },
688    /// `sni_preread_timeout` on a TCP listener exceeds that listener's
689    /// `front_timeout`. The preread phase is bounded by the frontend's own
690    /// inactivity timeout, so a preread budget longer than the timeout that
691    /// would kill the connection anyway can never fully elapse — it is
692    /// either a config mistake or silently dead configuration.
693    #[error(
694        "sni_preread_timeout = {sni_preread_timeout}s on TCP listener {address} exceeds its \
695         front_timeout = {front_timeout}s: the preread phase cannot outlive the frontend \
696         inactivity timeout that would already have closed the connection"
697    )]
698    SniPrereadTimeoutExceedsFrontTimeout {
699        address: SocketAddr,
700        sni_preread_timeout: u32,
701        front_timeout: u32,
702    },
703    /// `sni_preread_max_bytes` on a TCP listener targeted by at least one
704    /// SNI frontend exceeds the global `buffer_size`. The preread buffer is
705    /// carved out of the same per-session buffer the proxy uses for
706    /// relaying, so a preread ceiling above `buffer_size` can never be
707    /// reached in practice and signals a misconfiguration.
708    #[error(
709        "sni_preread_max_bytes = {sni_preread_max_bytes} on TCP listener {address} exceeds \
710         buffer_size = {buffer_size}: raise buffer_size to >= {sni_preread_max_bytes} or lower \
711         sni_preread_max_bytes"
712    )]
713    SniPrereadMaxBytesExceedsBufferSize {
714        address: SocketAddr,
715        sni_preread_max_bytes: u32,
716        buffer_size: u64,
717    },
718    /// `sni_preread_max_bytes` on a TCP listener targeted by at least one
719    /// SNI frontend is below [`MIN_SNI_PREREAD_MAX_BYTES`]. `0` in
720    /// particular makes the preread shell issue zero-length reads that can
721    /// never make progress -- the session spins until the event-loop
722    /// iteration guard (`MAX_LOOP_ITERATIONS`) trips instead of ever
723    /// completing a preread decision. The floor is a full TLS record
724    /// header (1-byte `ContentType` + 2-byte `ProtocolVersion` + 2-byte
725    /// length, RFC 8446 §5.1): below that, the shell cannot even learn how
726    /// many more bytes to wait for.
727    #[error(
728        "sni_preread_max_bytes = {sni_preread_max_bytes} on TCP listener {address} is below the \
729         minimum of {minimum} bytes (a full TLS record header): the preread shell could never \
730         read enough bytes to make progress. Raise sni_preread_max_bytes to >= {minimum}"
731    )]
732    SniPrereadMaxBytesTooSmall {
733        address: SocketAddr,
734        sni_preread_max_bytes: u32,
735        minimum: u32,
736    },
737}
738
739/// Config-facing mutual-TLS (client certificate authentication) mode, parsed
740/// from the `client_auth` key of an HTTPS listener's TOML section. Serializes as
741/// lowercase (`"none"` / `"optional"` / `"required"`) so operators write the
742/// documented values rather than the protobuf enum's `SCREAMING_SNAKE_CASE`.
743/// Maps to the wire [`ClientAuthMode`] in [`ListenerBuilder::to_tls`].
744#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
745#[serde(rename_all = "lowercase")]
746pub enum ClientAuthConfig {
747    #[default]
748    None,
749    Optional,
750    Required,
751}
752
753impl From<ClientAuthConfig> for ClientAuthMode {
754    fn from(value: ClientAuthConfig) -> Self {
755        match value {
756            ClientAuthConfig::None => ClientAuthMode::ClientAuthNone,
757            ClientAuthConfig::Optional => ClientAuthMode::ClientAuthOptional,
758            ClientAuthConfig::Required => ClientAuthMode::ClientAuthRequired,
759        }
760    }
761}
762
763/// An HTTP, HTTPS or TCP listener as parsed from the `Listeners` section in the toml
764#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
765#[serde(deny_unknown_fields)]
766pub struct ListenerBuilder {
767    pub address: SocketAddr,
768    /// Network interface the listening socket is bound to with
769    /// `SO_BINDTODEVICE` (e.g. `"wg0"`), for an address that is not known
770    /// ahead. Linux only: refused at config load on any other platform. Part
771    /// of the listener identity, so two listeners may share an `address` on
772    /// different interfaces.
773    pub interface: Option<String>,
774    pub protocol: Option<ListenerProtocol>,
775    pub public_address: Option<SocketAddr>,
776    pub answer_301: Option<String>,
777    pub answer_400: Option<String>,
778    pub answer_401: Option<String>,
779    pub answer_404: Option<String>,
780    pub answer_408: Option<String>,
781    pub answer_413: Option<String>,
782    /// RFC 9110 §15.5.20 — returned when the request's `:authority` / `Host`
783    /// host does not match the TLS SNI negotiated for this connection.
784    pub answer_421: Option<String>,
785    pub answer_502: Option<String>,
786    pub answer_503: Option<String>,
787    pub answer_504: Option<String>,
788    pub answer_507: Option<String>,
789    /// RFC 6585 §4 — emitted when a request would have reached a backend
790    /// but the per-(cluster, source-IP) connection limit is full. Honoured
791    /// like the other deprecated `answer_NNN` fields: copies into the
792    /// listener-level `answers` map at the matching status.
793    pub answer_429: Option<String>,
794    pub tls_versions: Option<Vec<TlsVersion>>,
795    pub cipher_list: Option<Vec<String>>,
796    pub cipher_suites: Option<Vec<String>>,
797    pub groups_list: Option<Vec<String>>,
798    pub expect_proxy: Option<bool>,
799    #[serde(default = "default_sticky_name")]
800    pub sticky_name: String,
801    pub certificate: Option<String>,
802    pub certificate_chain: Option<String>,
803    pub key: Option<String>,
804    /// maximum time of inactivity for a frontend socket
805    pub front_timeout: Option<u32>,
806    /// maximum time of inactivity for a backend socket
807    pub back_timeout: Option<u32>,
808    /// maximum time to connect to a backend server
809    pub connect_timeout: Option<u32>,
810    /// maximum time to receive a request since the connection started
811    pub request_timeout: Option<u32>,
812    /// A [Config] to pull defaults from
813    pub config: Option<Config>,
814    /// Number of TLS 1.3 tickets to send to a client when establishing a connection.
815    /// The ticket allow the client to resume a session. This protects the client
816    /// agains session tracking. Defaults to 4.
817    pub send_tls13_tickets: Option<u64>,
818    /// ALPN protocols to advertise during TLS handshake, in order of preference.
819    /// Valid values: "h2", "http/1.1". Defaults to ["h2", "http/1.1"].
820    pub alpn_protocols: Option<Vec<String>>,
821    /// H2 flood detection: max RST_STREAM frames per second window (CVE-2023-44487, CVE-2019-9514)
822    pub h2_max_rst_stream_per_window: Option<u32>,
823    /// H2 flood detection: max PING frames per second window (CVE-2019-9512)
824    pub h2_max_ping_per_window: Option<u32>,
825    /// H2 flood detection: max SETTINGS frames per second window (CVE-2019-9515)
826    pub h2_max_settings_per_window: Option<u32>,
827    /// H2 flood detection: max empty DATA frames per second window (CVE-2019-9518)
828    pub h2_max_empty_data_per_window: Option<u32>,
829    /// H2 flood detection: max unsolicited connection-level (stream 0)
830    /// WINDOW_UPDATE frames per sliding window — each DATA frame the proxy
831    /// sends entitles the peer to two that are not counted. Caps non-zero
832    /// stream-0 WINDOW_UPDATE floods that would otherwise stay under the
833    /// generic glitch counter. Default: 2000.
834    pub h2_max_window_update_stream0_per_window: Option<u32>,
835    /// Name of the correlation header Sozu injects into every request and
836    /// response. Default: `Sozu-Id`. Operators can rebrand (e.g. `X-Edge-Id`)
837    /// without touching code.
838    pub sozu_id_header: Option<String>,
839    /// H2 flood detection: max CONTINUATION frames per header block (CVE-2024-27316)
840    pub h2_max_continuation_frames: Option<u32>,
841    /// H2 flood detection: max accumulated protocol anomalies before
842    /// ENHANCE_YOUR_CALM. Default: 2000.
843    pub h2_max_glitch_count: Option<u32>,
844    /// H2 connection-level receive window size in bytes (RFC 9113 §6.9.2). Default: 16777216 (16 MiB).
845    pub h2_initial_connection_window: Option<u32>,
846    /// Maximum concurrent H2 streams (SETTINGS_MAX_CONCURRENT_STREAMS). Default: 100.
847    pub h2_max_concurrent_streams: Option<u32>,
848    /// Shrink threshold ratio for recycled stream slots. Default: 2.
849    pub h2_stream_shrink_ratio: Option<u32>,
850    /// H2 flood detection: floor of the connection-lifetime cap on RST_STREAM
851    /// frames received (CVE-2023-44487); trips once the count exceeds this
852    /// value and the resets received after a response started or on a closed
853    /// stream exceed the streams a backend answered. Default: 200000.
854    pub h2_max_rst_stream_lifetime: Option<u64>,
855    /// H2 flood detection: floor of the cap on "abusive" (pre-response-start)
856    /// RST_STREAM frames (Rapid Reset signature, CVE-2023-44487); trips once
857    /// the count exceeds this value and more than half of the
858    /// backend-routed streams were reset before their response.
859    /// Default: 1000.
860    pub h2_max_rst_stream_abusive_lifetime: Option<u64>,
861    /// H2 flood detection: floor of the cap on **server-emitted** RST_STREAM
862    /// frames the peer provoked (CVE-2025-8671 "MadeYouReset"); trips once the
863    /// count exceeds this value and more than half of the
864    /// backend-routed streams were reset before their response. Resets
865    /// Sōzu decides on its own and `NoError` resets are not counted.
866    /// Default: 10000.
867    pub h2_max_rst_stream_emitted_lifetime: Option<u64>,
868    /// H2 flood detection: maximum accumulated HPACK-decoded header list
869    /// size per request (SETTINGS_MAX_HEADER_LIST_SIZE, RFC 9113 §6.5.2).
870    /// Default: 65536.
871    pub h2_max_header_list_size: Option<u32>,
872    /// Maximum HPACK dynamic table size (SETTINGS_HEADER_TABLE_SIZE) accepted
873    /// from the peer. Caps the value the peer advertises in SETTINGS frames to
874    /// prevent unbounded HPACK encoder memory growth. Default: 65536.
875    pub h2_max_header_table_size: Option<u32>,
876    /// Maximum number of materialized header fields per request — HPACK fields
877    /// plus expanded cookie crumbs (RFC 9113 §8.2.3). Bounds the HPACK
878    /// indexed-reference header bomb. Default: 128.
879    pub h2_max_header_fields: Option<u32>,
880    /// H2 flood detection: share, in percent, of the pre-response RST_STREAM
881    /// cap's floor (`h2_max_rst_stream_abusive_lifetime`) at which a
882    /// connection refuses new client streams with `REFUSED_STREAM`
883    /// (retryable, RFC 9113 §8.7) and keeps serving its open streams. `0`
884    /// disables it; `100` or more never engages it before the cap.
885    /// Default: 50.
886    pub h2_stream_refusal_percent: Option<u32>,
887    /// Per-stream idle timeout, in seconds. An open H2 stream that makes no
888    /// forward progress for this duration is cancelled (RST_STREAM / CANCEL)
889    /// to defend against slow-multiplex Slowloris. Unset, it inherits
890    /// `back_timeout`, floored at 30 (so 30 with the default `back_timeout`);
891    /// an explicit value wins, `0` meaning 1.
892    pub h2_stream_idle_timeout_seconds: Option<u32>,
893    /// Maximum wall-clock seconds to wait for in-flight H2 streams after
894    /// `GOAWAY(NO_ERROR)` has been sent during soft-stop. Once the deadline
895    /// elapses the connection is forcibly closed with a final GOAWAY. Set to
896    /// `0` to wait for streams to finish (no forced close). Default: 5.
897    pub h2_graceful_shutdown_deadline_seconds: Option<u32>,
898    /// When true, every HTTP request served on this listener must have its
899    /// `:authority` / `Host` host exact-match the TLS SNI negotiated at
900    /// handshake (CWE-346 / CWE-444). Applies to HTTPS listeners only;
901    /// plaintext HTTP listeners never have an SNI to compare against.
902    /// Default: true.
903    pub strict_sni_binding: Option<bool>,
904    /// When true, this HTTPS listener only accepts HTTP/2 connections;
905    /// clients that do not negotiate `h2` via TLS ALPN (including those
906    /// that omit ALPN entirely) are dropped at handshake instead of
907    /// silently downgrading to HTTP/1.1. Default: false.
908    pub disable_http11: Option<bool>,
909    /// When true, any client-supplied `X-Real-IP` header is stripped from
910    /// requests before forwarding (anti-spoofing). Independently combinable
911    /// with `send_x_real_ip`. Default: false.
912    pub elide_x_real_ip: Option<bool>,
913    /// When true, a proxy-generated `X-Real-IP` header carrying the
914    /// connection peer IP (post-PROXY-v2 unwrap, i.e. the original client
915    /// IP) is appended to every forwarded request. Independently combinable
916    /// with `elide_x_real_ip`. Default: false.
917    pub send_x_real_ip: Option<bool>,
918    /// Which forwarding header family this listener adds to every forwarded
919    /// request (see [`ForwardedHeadersMode`]). Independent of
920    /// `elide_x_real_ip` and `send_x_real_ip`. Default: `both`.
921    pub forwarded_headers: Option<ForwardedHeadersMode>,
922    /// Per-status HTTP answer templates at listener scope — the **global
923    /// default** that fires whenever no cluster-level override matches.
924    /// Map key is the HTTP status code (e.g. `"503"`); map value is
925    /// either a filesystem path or an `inline:<body>` literal, see
926    /// [`resolve_answer_source`]. Loaded into
927    /// [`HttpListenerConfig::answers`] / [`HttpsListenerConfig::answers`]
928    /// at build time via [`load_answers`].
929    ///
930    /// Cluster-level [`FileClusterConfig::answers`] entries override the
931    /// matching status here for requests routed to that cluster.
932    ///
933    /// The deprecated per-status `answer_NNN` fields are still honoured
934    /// for backwards compatibility but are equivalent to a one-line entry
935    /// in this map; new configs should prefer `[listeners.answers]`.
936    pub answers: Option<BTreeMap<String, String>>,
937    /// Listener-default HSTS (RFC 6797) policy. When set, every HTTPS
938    /// frontend on this listener that does not declare its own `[hsts]`
939    /// block inherits this value. Per RFC 6797 §7.2 HSTS is rejected on
940    /// HTTP listeners at config-load time; this field is only meaningful
941    /// for HTTPS listeners. Defaults to `None` (no HSTS).
942    pub hsts: Option<FileHstsConfig>,
943    /// UDP listener only: maximum received datagram size, in bytes. Capped
944    /// at the effective `buffer_size` at config-load (clamp + warn when
945    /// larger). Defaults to [`DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE`].
946    pub max_rx_datagram_size: Option<u32>,
947    /// UDP listener only: maximum number of concurrent flows. `0` (the
948    /// default) selects the runtime auto policy (~70% soft RLIMIT_NOFILE);
949    /// a warning is emitted at config-load when an explicit value exceeds
950    /// that bound.
951    pub max_flows: Option<u32>,
952    /// TCP listener only: time allowed to receive enough bytes of the TLS
953    /// ClientHello to read the SNI extension, in seconds. Only meaningful
954    /// when at least one SNI-scoped frontend targets this listener.
955    /// Defaults to [`DEFAULT_SNI_PREREAD_TIMEOUT`].
956    pub sni_preread_timeout: Option<u32>,
957    /// TCP listener only: maximum number of bytes buffered while prereading
958    /// the TLS ClientHello looking for the SNI extension. Only meaningful
959    /// when at least one SNI-scoped frontend targets this listener; must
960    /// not exceed the global `buffer_size` (validated at config-load).
961    /// Defaults to [`DEFAULT_SNI_PREREAD_MAX_BYTES`].
962    pub sni_preread_max_bytes: Option<u32>,
963    /// HTTPS listener only: mutual TLS (client certificate authentication)
964    /// mode (`none` / `optional` / `required`). Absent and `none` both keep the
965    /// historical no-client-auth behavior.
966    pub client_auth: Option<ClientAuthConfig>,
967    /// HTTPS listener only: filesystem paths to PEM-encoded trusted CA
968    /// certificates a client certificate must chain to. Loaded at
969    /// config-materialization time (like `certificate`/`key`) and inlined as
970    /// PEM into the resulting [`HttpsListenerConfig`].
971    pub client_ca_certificates: Option<Vec<String>>,
972    /// HTTPS listener only: filesystem paths to PEM-encoded CRLs used to
973    /// reject revoked client certificates. Loaded alongside
974    /// `client_ca_certificates`.
975    pub client_ca_crls: Option<Vec<String>>,
976}
977
978pub fn default_sticky_name() -> String {
979    DEFAULT_STICKY_NAME.to_string()
980}
981
982impl ListenerBuilder {
983    /// starts building an HTTP Listener with config values for timeouts,
984    /// or defaults if no config is provided
985    pub fn new_http(address: SocketAddress) -> ListenerBuilder {
986        Self::new(address, ListenerProtocol::Http)
987    }
988
989    /// starts building an HTTPS Listener with config values for timeouts,
990    /// or defaults if no config is provided
991    pub fn new_tcp(address: SocketAddress) -> ListenerBuilder {
992        Self::new(address, ListenerProtocol::Tcp)
993    }
994
995    /// starts building a TCP Listener with config values for timeouts,
996    /// or defaults if no config is provided
997    pub fn new_https(address: SocketAddress) -> ListenerBuilder {
998        Self::new(address, ListenerProtocol::Https)
999    }
1000
1001    /// starts building a UDP Listener with config values for timeouts,
1002    /// or defaults if no config is provided
1003    pub fn new_udp(address: SocketAddress) -> ListenerBuilder {
1004        Self::new(address, ListenerProtocol::Udp)
1005    }
1006
1007    /// starts building a Listener
1008    fn new(address: SocketAddress, protocol: ListenerProtocol) -> ListenerBuilder {
1009        ListenerBuilder {
1010            address: address.into(),
1011            interface: None,
1012            answer_301: None,
1013            answer_401: None,
1014            answer_400: None,
1015            answer_404: None,
1016            answer_408: None,
1017            answer_413: None,
1018            answer_421: None,
1019            answer_502: None,
1020            answer_503: None,
1021            answer_504: None,
1022            answer_507: None,
1023            answer_429: None,
1024            back_timeout: None,
1025            certificate_chain: None,
1026            certificate: None,
1027            cipher_list: None,
1028            cipher_suites: None,
1029            groups_list: None,
1030            config: None,
1031            connect_timeout: None,
1032            expect_proxy: None,
1033            front_timeout: None,
1034            key: None,
1035            protocol: Some(protocol),
1036            public_address: None,
1037            request_timeout: None,
1038            send_tls13_tickets: None,
1039            sticky_name: DEFAULT_STICKY_NAME.to_string(),
1040            tls_versions: None,
1041            alpn_protocols: None,
1042            h2_max_rst_stream_per_window: None,
1043            h2_max_ping_per_window: None,
1044            h2_max_settings_per_window: None,
1045            h2_max_empty_data_per_window: None,
1046            h2_max_window_update_stream0_per_window: None,
1047            sozu_id_header: None,
1048            h2_max_continuation_frames: None,
1049            h2_max_glitch_count: None,
1050            h2_initial_connection_window: None,
1051            h2_max_concurrent_streams: None,
1052            h2_stream_shrink_ratio: None,
1053            h2_max_rst_stream_lifetime: None,
1054            h2_max_rst_stream_abusive_lifetime: None,
1055            h2_max_rst_stream_emitted_lifetime: None,
1056            h2_max_header_list_size: None,
1057            h2_max_header_table_size: None,
1058            h2_max_header_fields: None,
1059            h2_stream_refusal_percent: None,
1060            h2_stream_idle_timeout_seconds: None,
1061            h2_graceful_shutdown_deadline_seconds: None,
1062            strict_sni_binding: None,
1063            disable_http11: None,
1064            elide_x_real_ip: None,
1065            send_x_real_ip: None,
1066            forwarded_headers: None,
1067            answers: None,
1068            hsts: None,
1069            max_rx_datagram_size: None,
1070            max_flows: None,
1071            sni_preread_timeout: None,
1072            sni_preread_max_bytes: None,
1073            client_auth: None,
1074            client_ca_certificates: None,
1075            client_ca_crls: None,
1076        }
1077    }
1078
1079    /// Set the network interface the listener binds to; `None` clears it.
1080    pub fn with_interface<S>(&mut self, interface: Option<S>) -> &mut Self
1081    where
1082        S: ToString,
1083    {
1084        self.interface = interface.map(|interface| interface.to_string());
1085        self
1086    }
1087
1088    /// The validated `interface`: a valid interface name, on a platform that
1089    /// can bind a socket to an interface.
1090    fn validated_interface(&self) -> Result<Option<String>, ConfigError> {
1091        if let Some(interface) = &self.interface {
1092            validate_listener_interface(interface).map_err(|error| {
1093                ConfigError::ListenerInterface {
1094                    address: self.address,
1095                    error,
1096                }
1097            })?;
1098        }
1099        Ok(self.interface.clone())
1100    }
1101
1102    pub fn with_public_address(&mut self, public_address: Option<SocketAddr>) -> &mut Self {
1103        if let Some(address) = public_address {
1104            self.public_address = Some(address);
1105        }
1106        self
1107    }
1108
1109    pub fn with_answer_404_path<S>(&mut self, answer_404_path: Option<S>) -> &mut Self
1110    where
1111        S: ToString,
1112    {
1113        if let Some(path) = answer_404_path {
1114            self.answer_404 = Some(path.to_string());
1115        }
1116        self
1117    }
1118
1119    pub fn with_answer_503_path<S>(&mut self, answer_503_path: Option<S>) -> &mut Self
1120    where
1121        S: ToString,
1122    {
1123        if let Some(path) = answer_503_path {
1124            self.answer_503 = Some(path.to_string());
1125        }
1126        self
1127    }
1128
1129    pub fn with_tls_versions(&mut self, tls_versions: Vec<TlsVersion>) -> &mut Self {
1130        self.tls_versions = Some(tls_versions);
1131        self
1132    }
1133
1134    pub fn with_cipher_list(&mut self, cipher_list: Option<Vec<String>>) -> &mut Self {
1135        self.cipher_list = cipher_list;
1136        self
1137    }
1138
1139    pub fn with_cipher_suites(&mut self, cipher_suites: Option<Vec<String>>) -> &mut Self {
1140        self.cipher_suites = cipher_suites;
1141        self
1142    }
1143
1144    pub fn with_alpn_protocols(&mut self, alpn_protocols: Option<Vec<String>>) -> &mut Self {
1145        self.alpn_protocols = alpn_protocols;
1146        self
1147    }
1148
1149    /// When true, strip any client-supplied `X-Real-IP` header from
1150    /// forwarded requests (anti-spoofing). Default: false.
1151    pub fn with_elide_x_real_ip(&mut self, elide_x_real_ip: bool) -> &mut Self {
1152        self.elide_x_real_ip = Some(elide_x_real_ip);
1153        self
1154    }
1155
1156    /// When true, append a proxy-generated `X-Real-IP` header carrying the
1157    /// connection peer IP (post-PROXY-v2 unwrap) to every forwarded request.
1158    /// Default: false.
1159    pub fn with_send_x_real_ip(&mut self, send_x_real_ip: bool) -> &mut Self {
1160        self.send_x_real_ip = Some(send_x_real_ip);
1161        self
1162    }
1163
1164    /// Which forwarding header family this listener adds to every forwarded
1165    /// request. Default: [`ForwardedHeadersMode::Both`].
1166    pub fn with_forwarded_headers(&mut self, forwarded_headers: ForwardedHeadersMode) -> &mut Self {
1167        self.forwarded_headers = Some(forwarded_headers);
1168        self
1169    }
1170
1171    pub fn with_expect_proxy(&mut self, expect_proxy: bool) -> &mut Self {
1172        self.expect_proxy = Some(expect_proxy);
1173        self
1174    }
1175
1176    pub fn with_sticky_name<S>(&mut self, sticky_name: Option<S>) -> &mut Self
1177    where
1178        S: ToString,
1179    {
1180        if let Some(name) = sticky_name {
1181            self.sticky_name = name.to_string();
1182        }
1183        self
1184    }
1185
1186    pub fn with_certificate<S>(&mut self, certificate: S) -> &mut Self
1187    where
1188        S: ToString,
1189    {
1190        self.certificate = Some(certificate.to_string());
1191        self
1192    }
1193
1194    pub fn with_certificate_chain(&mut self, certificate_chain: String) -> &mut Self {
1195        self.certificate = Some(certificate_chain);
1196        self
1197    }
1198
1199    pub fn with_key<S>(&mut self, key: String) -> &mut Self
1200    where
1201        S: ToString,
1202    {
1203        self.key = Some(key);
1204        self
1205    }
1206
1207    pub fn with_front_timeout(&mut self, front_timeout: Option<u32>) -> &mut Self {
1208        self.front_timeout = front_timeout;
1209        self
1210    }
1211
1212    pub fn with_back_timeout(&mut self, back_timeout: Option<u32>) -> &mut Self {
1213        self.back_timeout = back_timeout;
1214        self
1215    }
1216
1217    pub fn with_connect_timeout(&mut self, connect_timeout: Option<u32>) -> &mut Self {
1218        self.connect_timeout = connect_timeout;
1219        self
1220    }
1221
1222    pub fn with_request_timeout(&mut self, request_timeout: Option<u32>) -> &mut Self {
1223        self.request_timeout = request_timeout;
1224        self
1225    }
1226
1227    /// Register a single per-status answer template file path on this
1228    /// listener. The path is read off disk into the resulting listener's
1229    /// `answers` map at build time via [`load_answers`]. Repeated calls
1230    /// with the same status code overwrite the prior entry.
1231    pub fn with_answer<S, P>(&mut self, code: S, path: P) -> &mut Self
1232    where
1233        S: ToString,
1234        P: ToString,
1235    {
1236        self.answers
1237            .get_or_insert_with(BTreeMap::new)
1238            .insert(code.to_string(), path.to_string());
1239        self
1240    }
1241
1242    /// Replace the listener-scope answer-template path map. See
1243    /// [`Self::with_answer`].
1244    pub fn with_answers(&mut self, answers: BTreeMap<String, String>) -> &mut Self {
1245        self.answers = Some(answers);
1246        self
1247    }
1248
1249    /// Get the custom HTTP answers from the file system using the provided paths
1250    fn get_http_answers(&self) -> Result<Option<CustomHttpAnswers>, ConfigError> {
1251        let http_answers = CustomHttpAnswers {
1252            answer_301: read_http_answer_file(&self.answer_301)?,
1253            answer_400: read_http_answer_file(&self.answer_400)?,
1254            answer_401: read_http_answer_file(&self.answer_401)?,
1255            answer_404: read_http_answer_file(&self.answer_404)?,
1256            answer_408: read_http_answer_file(&self.answer_408)?,
1257            answer_413: read_http_answer_file(&self.answer_413)?,
1258            answer_421: read_http_answer_file(&self.answer_421)?,
1259            answer_502: read_http_answer_file(&self.answer_502)?,
1260            answer_503: read_http_answer_file(&self.answer_503)?,
1261            answer_504: read_http_answer_file(&self.answer_504)?,
1262            answer_507: read_http_answer_file(&self.answer_507)?,
1263            answer_429: read_http_answer_file(&self.answer_429)?,
1264        };
1265        Ok(Some(http_answers))
1266    }
1267
1268    /// Build the proto-side `answers` map for this listener.
1269    ///
1270    /// Merges, in order:
1271    /// 1. legacy per-status `answer_NNN` fields (if set), so legacy state
1272    ///    files round-trip into the new shape;
1273    /// 2. the explicit `[listeners.answers]` map (loaded via [`load_answers`]),
1274    ///    so new entries take precedence over legacy ones.
1275    fn get_listener_answers(&self) -> Result<BTreeMap<String, String>, ConfigError> {
1276        let mut out = BTreeMap::new();
1277
1278        // Pull bodies from the legacy per-status fields first so the new map
1279        // takes precedence on collision. Empty bodies are skipped to keep the
1280        // proto map minimal.
1281        macro_rules! merge_legacy {
1282            ($code:literal, $field:ident) => {
1283                if let Some(body) = read_http_answer_file(&self.$field)? {
1284                    out.insert($code.to_owned(), body);
1285                }
1286            };
1287        }
1288        merge_legacy!("301", answer_301);
1289        merge_legacy!("400", answer_400);
1290        merge_legacy!("401", answer_401);
1291        merge_legacy!("404", answer_404);
1292        merge_legacy!("408", answer_408);
1293        merge_legacy!("413", answer_413);
1294        merge_legacy!("421", answer_421);
1295        merge_legacy!("502", answer_502);
1296        merge_legacy!("503", answer_503);
1297        merge_legacy!("504", answer_504);
1298        merge_legacy!("507", answer_507);
1299        merge_legacy!("429", answer_429);
1300
1301        if let Some(map) = &self.answers {
1302            let loaded = load_answers(map)?;
1303            out.extend(loaded);
1304        }
1305        Ok(out)
1306    }
1307
1308    /// Assign the timeouts of the config to this listener, only if timeouts did not exist
1309    fn assign_config_timeouts(&mut self, config: &Config) {
1310        self.front_timeout = Some(self.front_timeout.unwrap_or(config.front_timeout));
1311        self.back_timeout = Some(self.back_timeout.unwrap_or(config.back_timeout));
1312        self.connect_timeout = Some(self.connect_timeout.unwrap_or(config.connect_timeout));
1313        self.request_timeout = Some(self.request_timeout.unwrap_or(config.request_timeout));
1314    }
1315
1316    /// Reject an H2 knob the runtime cannot honour, before it reaches a
1317    /// listener config (sozu-proxy/sozu#1418).
1318    ///
1319    /// `UpdateHttp(s)Listener` has refused these values since the knobs
1320    /// existed (`validate_h2_flood_knobs_http`/`_https`, `command/src/state.rs`)
1321    /// — the config file and `sozu ctl add listener` did not, so the same `0`
1322    /// an operator could not patch in, they could boot with. It reached
1323    /// `HttpListenerConfig` untouched, and `lib/src/http.rs::get_h2_flood_config`
1324    /// handed it to the flood detector, whose `check_flood` compares
1325    /// `count > threshold`: the first event the counter saw was already a
1326    /// violation. For `h2_max_header_list_size`/`h2_max_header_fields`, which
1327    /// double as the HPACK decode budget, that is every request on the
1328    /// listener; for the per-window and lifetime frame counters it is the
1329    /// first client that sends the frame they count. Rejecting here rather
1330    /// than clamping at use keeps the operator's mistake visible — the error
1331    /// names the key, the value and the minimum.
1332    ///
1333    /// The field set and the minimums are deliberately the same as
1334    /// `validate_h2_flood_knobs_http`'s: a value one door refuses must not get
1335    /// in through the other. `h2_graceful_shutdown_deadline_seconds = 0` stays
1336    /// allowed there and here — it means "wait forever".
1337    fn validate_h2_thresholds(&self) -> Result<(), ConfigError> {
1338        macro_rules! require_at_least {
1339            ($builder:ident, $field:ident, $minimum:literal) => {
1340                if let Some(value) = $builder.$field
1341                    && u64::from(value) < $minimum
1342                {
1343                    return Err(ConfigError::H2ThresholdBelowMinimum {
1344                        address: $builder.address.to_string(),
1345                        key: stringify!($field),
1346                        value: u64::from(value),
1347                        minimum: $minimum,
1348                    });
1349                }
1350            };
1351        }
1352        crate::for_each_h2_knob_floor!(require_at_least, self);
1353        Ok(())
1354    }
1355
1356    /// Reject mTLS fields on a non-HTTPS listener. Client auth is an
1357    /// HTTPS-termination control; the HTTP/TCP/UDP conversions have no field to
1358    /// carry it, so an operator who sets it there must get a typed error rather
1359    /// than a silently unauthenticated listener.
1360    fn reject_client_auth_fields(&self, listener_kind: &str) -> Result<(), ConfigError> {
1361        let has_mtls = self
1362            .client_auth
1363            .is_some_and(|m| m != ClientAuthConfig::None)
1364            || self
1365                .client_ca_certificates
1366                .as_ref()
1367                .is_some_and(|v| !v.is_empty())
1368            || self.client_ca_crls.as_ref().is_some_and(|v| !v.is_empty());
1369        if has_mtls {
1370            return Err(ConfigError::ClientAuthOnNonHttps(format!(
1371                "{} listener {}",
1372                listener_kind, self.address
1373            )));
1374        }
1375        Ok(())
1376    }
1377
1378    /// build an HTTP listener with config timeouts, using defaults if no config is provided
1379    pub fn to_http(&mut self, config: Option<&Config>) -> Result<HttpListenerConfig, ConfigError> {
1380        if self.protocol != Some(ListenerProtocol::Http) {
1381            return Err(ConfigError::WrongListenerProtocol {
1382                expected: ListenerProtocol::Http,
1383                found: self.protocol.to_owned(),
1384            });
1385        }
1386        self.reject_client_auth_fields("HTTP")?;
1387
1388        self.validate_h2_thresholds()?;
1389
1390        // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on
1391        // plaintext-HTTP responses. Reject an `[hsts]` block on an HTTP
1392        // listener at config-load — `HttpListenerConfig` has no `hsts`
1393        // field, so silently dropping the operator's intent would be a
1394        // worse failure mode than a typed error here.
1395        if self.hsts.is_some() {
1396            return Err(ConfigError::HstsOnPlainHttp(format!(
1397                "HTTP listener {}",
1398                self.address
1399            )));
1400        }
1401
1402        if let Some(config) = config {
1403            self.assign_config_timeouts(config);
1404        }
1405
1406        let http_answers = self.get_http_answers()?;
1407        let answers = self.get_listener_answers()?;
1408
1409        let interface = self.validated_interface()?;
1410        let configuration = HttpListenerConfig {
1411            address: self.address.into(),
1412            interface,
1413            public_address: self.public_address.map(|a| a.into()),
1414            expect_proxy: self.expect_proxy.unwrap_or(false),
1415            sticky_name: self.sticky_name.clone(),
1416            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1417            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1418            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1419            request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
1420            http_answers,
1421            answers,
1422            h2_max_rst_stream_per_window: self.h2_max_rst_stream_per_window,
1423            h2_max_ping_per_window: self.h2_max_ping_per_window,
1424            h2_max_settings_per_window: self.h2_max_settings_per_window,
1425            h2_max_empty_data_per_window: self.h2_max_empty_data_per_window,
1426            h2_max_window_update_stream0_per_window: self.h2_max_window_update_stream0_per_window,
1427            h2_max_continuation_frames: self.h2_max_continuation_frames,
1428            h2_max_glitch_count: self.h2_max_glitch_count,
1429            h2_initial_connection_window: self.h2_initial_connection_window,
1430            h2_max_concurrent_streams: self.h2_max_concurrent_streams,
1431            h2_stream_shrink_ratio: self.h2_stream_shrink_ratio,
1432            h2_max_rst_stream_lifetime: self.h2_max_rst_stream_lifetime,
1433            h2_max_rst_stream_abusive_lifetime: self.h2_max_rst_stream_abusive_lifetime,
1434            h2_max_rst_stream_emitted_lifetime: self.h2_max_rst_stream_emitted_lifetime,
1435            h2_max_header_list_size: self.h2_max_header_list_size,
1436            h2_max_header_table_size: self.h2_max_header_table_size,
1437            h2_max_header_fields: self.h2_max_header_fields,
1438            h2_stream_refusal_percent: self.h2_stream_refusal_percent,
1439            h2_stream_idle_timeout_seconds: self.h2_stream_idle_timeout_seconds,
1440            h2_graceful_shutdown_deadline_seconds: self.h2_graceful_shutdown_deadline_seconds,
1441            sozu_id_header: self.sozu_id_header.clone(),
1442            elide_x_real_ip: Some(self.elide_x_real_ip.unwrap_or(false)),
1443            send_x_real_ip: Some(self.send_x_real_ip.unwrap_or(false)),
1444            forwarded_headers: Some(ForwardedHeaders::from(
1445                self.forwarded_headers.unwrap_or_default(),
1446            ) as i32),
1447            ..Default::default()
1448        };
1449
1450        // POST: the built listener binds exactly the address that was
1451        // requested — a listener whose address drifted here would bind the
1452        // wrong socket. (We reached this point only because the protocol guard
1453        // at entry confirmed this is an HTTP listener.)
1454        debug_assert_eq!(
1455            configuration.address,
1456            self.address.into(),
1457            "HTTP listener must bind the requested address"
1458        );
1459        Ok(configuration)
1460    }
1461
1462    /// build an HTTPS listener using defaults if no config or values were provided upstream
1463    pub fn to_tls(&mut self, config: Option<&Config>) -> Result<HttpsListenerConfig, ConfigError> {
1464        if self.protocol != Some(ListenerProtocol::Https) {
1465            return Err(ConfigError::WrongListenerProtocol {
1466                expected: ListenerProtocol::Https,
1467                found: self.protocol.to_owned(),
1468            });
1469        }
1470
1471        self.validate_h2_thresholds()?;
1472
1473        let default_cipher_list = DEFAULT_CIPHER_LIST.into_iter().map(String::from).collect();
1474
1475        let cipher_list = self.cipher_list.clone().unwrap_or(default_cipher_list);
1476
1477        let cipher_suites = self
1478            .cipher_suites
1479            .clone()
1480            .unwrap_or_else(|| DEFAULT_CIPHER_LIST.into_iter().map(String::from).collect());
1481
1482        let signature_algorithms: Vec<String> = DEFAULT_SIGNATURE_ALGORITHMS
1483            .into_iter()
1484            .map(String::from)
1485            .collect();
1486
1487        let groups_list = self
1488            .groups_list
1489            .clone()
1490            .unwrap_or_else(|| DEFAULT_GROUPS_LIST.into_iter().map(String::from).collect());
1491
1492        let alpn_protocols: Vec<String> = match &self.alpn_protocols {
1493            Some(protos) if !protos.is_empty() => {
1494                for proto in protos {
1495                    match proto.as_str() {
1496                        "h2" | "http/1.1" => {}
1497                        other => return Err(ConfigError::InvalidAlpnProtocol(other.to_owned())),
1498                    }
1499                }
1500                // disable_http11 + http/1.1 ALPN is a self-DoS — every
1501                // connection negotiates http/1.1 then is
1502                // immediately refused at `https.rs::upgrade_handshake`.
1503                // Reject the combination at config load.
1504                if self.disable_http11.unwrap_or(false) && protos.iter().any(|p| p == "http/1.1") {
1505                    return Err(ConfigError::DisableHttp11WithHttp11Alpn {
1506                        address: self.address.to_string(),
1507                    });
1508                }
1509                if !protos.iter().any(|p| p == "http/1.1") {
1510                    warn!(
1511                        "ALPN protocols do not include 'http/1.1'. Clients without H2 support will fail TLS negotiation."
1512                    );
1513                }
1514                // Deduplicate while preserving order
1515                let mut seen = std::collections::HashSet::new();
1516                protos
1517                    .iter()
1518                    .filter(|p| seen.insert(p.as_str()))
1519                    .cloned()
1520                    .collect()
1521            }
1522            _ => {
1523                // Same self-DoS check on the default ALPN list (which
1524                // contains "http/1.1") — `disable_http11 = true` with the
1525                // implicit default ALPN must also be rejected.
1526                if self.disable_http11.unwrap_or(false)
1527                    && DEFAULT_ALPN_PROTOCOLS.contains(&"http/1.1")
1528                {
1529                    return Err(ConfigError::DisableHttp11WithHttp11Alpn {
1530                        address: self.address.to_string(),
1531                    });
1532                }
1533                DEFAULT_ALPN_PROTOCOLS
1534                    .iter()
1535                    .map(|s| s.to_string())
1536                    .collect()
1537            }
1538        };
1539
1540        let versions = match self.tls_versions {
1541            None => vec![TlsVersion::TlsV12 as i32, TlsVersion::TlsV13 as i32],
1542            Some(ref v) => v.iter().map(|v| *v as i32).collect(),
1543        };
1544
1545        let key = self.key.as_ref().and_then(|path| {
1546            Config::load_file(path)
1547                .map_err(|e| {
1548                    error!("cannot load key at path '{}': {:?}", path, e);
1549                    e
1550                })
1551                .ok()
1552        });
1553        let certificate = self.certificate.as_ref().and_then(|path| {
1554            Config::load_file(path)
1555                .map_err(|e| {
1556                    error!("cannot load certificate at path '{}': {:?}", path, e);
1557                    e
1558                })
1559                .ok()
1560        });
1561        let certificate_chain = self
1562            .certificate_chain
1563            .as_ref()
1564            .and_then(|path| {
1565                Config::load_file(path)
1566                    .map_err(|e| {
1567                        error!("cannot load certificate chain at path '{}': {:?}", path, e);
1568                        e
1569                    })
1570                    .ok()
1571            })
1572            .map(split_certificate_chain)
1573            .unwrap_or_default();
1574
1575        // mTLS: load the trusted-CA and CRL PEM bundles from disk, inlining
1576        // their contents (like `certificate`/`key` above). Any unreadable path
1577        // aborts materialization: a dropped CA would weaken trust, and a
1578        // dropped CRL would silently disable revocation for certificates the
1579        // operator meant to reject. Failing closed keeps client auth honest.
1580        //
1581        // Files are read only for OPTIONAL/REQUIRED. In NONE the runtime
1582        // ignores CA/CRL data, so a stale path left in the config must not
1583        // block the whole configuration from loading. That tolerance needs an
1584        // explicit `client_auth = "none"`: CA or CRL files with no mode at all
1585        // most likely mean the operator expected client authentication, and
1586        // the default would start the listener without it.
1587        let has_client_auth_files = self
1588            .client_ca_certificates
1589            .as_ref()
1590            .is_some_and(|paths| !paths.is_empty())
1591            || self
1592                .client_ca_crls
1593                .as_ref()
1594                .is_some_and(|paths| !paths.is_empty());
1595        match self.client_auth {
1596            None if has_client_auth_files => {
1597                return Err(ConfigError::ClientAuthModeMissing(format!(
1598                    "HTTPS listener {}",
1599                    self.address
1600                )));
1601            }
1602            Some(ClientAuthConfig::None) if has_client_auth_files => {
1603                warn!(
1604                    "HTTPS listener {}: client_auth is \"none\", so its client_ca_certificates \
1605                     and client_ca_crls are ignored and clients are not authenticated",
1606                    self.address
1607                );
1608            }
1609            _ => {}
1610        }
1611        let mode = self.client_auth.unwrap_or_default();
1612        let client_auth = self
1613            .client_auth
1614            .map(|mode| ClientAuthMode::from(mode) as i32);
1615        let load_pem_paths = |paths: &Option<Vec<String>>| -> Result<Vec<String>, ConfigError> {
1616            if mode == ClientAuthConfig::None {
1617                return Ok(Vec::new());
1618            }
1619            paths
1620                .as_ref()
1621                .map(|list| list.iter().map(|path| Config::load_file(path)).collect())
1622                .unwrap_or_else(|| Ok(Vec::new()))
1623        };
1624        let client_ca_certificates = load_pem_paths(&self.client_ca_certificates)?;
1625        let client_ca_crls = load_pem_paths(&self.client_ca_crls)?;
1626
1627        let http_answers = self.get_http_answers()?;
1628        let answers = self.get_listener_answers()?;
1629
1630        if let Some(config) = config {
1631            self.assign_config_timeouts(config);
1632        }
1633
1634        let https_listener_config = HttpsListenerConfig {
1635            interface: self.validated_interface()?,
1636            address: self.address.into(),
1637            sticky_name: self.sticky_name.clone(),
1638            public_address: self.public_address.map(|a| a.into()),
1639            cipher_list,
1640            versions,
1641            expect_proxy: self.expect_proxy.unwrap_or(false),
1642            key,
1643            certificate,
1644            certificate_chain,
1645            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1646            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1647            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1648            request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
1649            cipher_suites,
1650            signature_algorithms,
1651            groups_list,
1652            active: false,
1653            send_tls13_tickets: self
1654                .send_tls13_tickets
1655                .unwrap_or(DEFAULT_SEND_TLS_13_TICKETS),
1656            http_answers,
1657            answers,
1658            alpn_protocols,
1659            h2_max_rst_stream_per_window: self.h2_max_rst_stream_per_window,
1660            h2_max_ping_per_window: self.h2_max_ping_per_window,
1661            h2_max_settings_per_window: self.h2_max_settings_per_window,
1662            h2_max_empty_data_per_window: self.h2_max_empty_data_per_window,
1663            h2_max_window_update_stream0_per_window: self.h2_max_window_update_stream0_per_window,
1664            h2_max_continuation_frames: self.h2_max_continuation_frames,
1665            h2_max_glitch_count: self.h2_max_glitch_count,
1666            h2_initial_connection_window: self.h2_initial_connection_window,
1667            h2_max_concurrent_streams: self.h2_max_concurrent_streams,
1668            h2_stream_shrink_ratio: self.h2_stream_shrink_ratio,
1669            h2_max_rst_stream_lifetime: self.h2_max_rst_stream_lifetime,
1670            h2_max_rst_stream_abusive_lifetime: self.h2_max_rst_stream_abusive_lifetime,
1671            h2_max_rst_stream_emitted_lifetime: self.h2_max_rst_stream_emitted_lifetime,
1672            h2_max_header_list_size: self.h2_max_header_list_size,
1673            h2_max_header_table_size: self.h2_max_header_table_size,
1674            h2_max_header_fields: self.h2_max_header_fields,
1675            h2_stream_refusal_percent: self.h2_stream_refusal_percent,
1676            strict_sni_binding: self.strict_sni_binding,
1677            disable_http11: self.disable_http11,
1678            h2_stream_idle_timeout_seconds: self.h2_stream_idle_timeout_seconds,
1679            h2_graceful_shutdown_deadline_seconds: self.h2_graceful_shutdown_deadline_seconds,
1680            sozu_id_header: self.sozu_id_header.clone(),
1681            elide_x_real_ip: Some(self.elide_x_real_ip.unwrap_or(false)),
1682            send_x_real_ip: Some(self.send_x_real_ip.unwrap_or(false)),
1683            forwarded_headers: Some(ForwardedHeaders::from(
1684                self.forwarded_headers.unwrap_or_default(),
1685            ) as i32),
1686            hsts: match self.hsts.as_ref() {
1687                Some(h) => Some(h.to_proto("listener")?),
1688                None => None,
1689            },
1690            client_auth,
1691            client_ca_certificates,
1692            client_ca_crls,
1693        };
1694
1695        // POST: the built listener binds the requested address and starts
1696        // inactive (the protocol guard at entry confirmed this is an HTTPS
1697        // listener).
1698        debug_assert_eq!(
1699            https_listener_config.address,
1700            self.address.into(),
1701            "HTTPS listener must bind the requested address"
1702        );
1703        debug_assert!(
1704            !https_listener_config.active,
1705            "a freshly built HTTPS listener must start inactive"
1706        );
1707        // POST: the resolved ALPN list is non-empty, contains only the two
1708        // protocols Sōzu speaks, and is duplicate-free — the validation/dedup
1709        // branches above are the sole producers, so a malformed list here would
1710        // mean an unvalidated path slipped through.
1711        debug_assert!(
1712            !https_listener_config.alpn_protocols.is_empty(),
1713            "resolved ALPN list must not be empty"
1714        );
1715        debug_assert!(
1716            https_listener_config
1717                .alpn_protocols
1718                .iter()
1719                .all(|p| p == "h2" || p == "http/1.1"),
1720            "resolved ALPN list must contain only h2 and http/1.1"
1721        );
1722        debug_assert!(
1723            {
1724                let mut seen = std::collections::HashSet::new();
1725                https_listener_config
1726                    .alpn_protocols
1727                    .iter()
1728                    .all(|p| seen.insert(p))
1729            },
1730            "resolved ALPN list must be duplicate-free"
1731        );
1732        // POST: disable_http11 + http/1.1 in ALPN is a self-DoS that the
1733        // validation above rejects — an Ok return must never carry that combo.
1734        debug_assert!(
1735            !(self.disable_http11.unwrap_or(false)
1736                && https_listener_config
1737                    .alpn_protocols
1738                    .iter()
1739                    .any(|p| p == "http/1.1")),
1740            "disable_http11 with http/1.1 in ALPN must have been rejected"
1741        );
1742        Ok(https_listener_config)
1743    }
1744
1745    /// build an HTTPS listener using defaults if no config or values were provided upstream
1746    pub fn to_tcp(&mut self, config: Option<&Config>) -> Result<TcpListenerConfig, ConfigError> {
1747        if self.protocol != Some(ListenerProtocol::Tcp) {
1748            return Err(ConfigError::WrongListenerProtocol {
1749                expected: ListenerProtocol::Tcp,
1750                found: self.protocol.to_owned(),
1751            });
1752        }
1753        self.reject_client_auth_fields("TCP")?;
1754
1755        if let Some(config) = config {
1756            self.assign_config_timeouts(config);
1757        }
1758
1759        let tcp_listener_config = TcpListenerConfig {
1760            interface: self.validated_interface()?,
1761            address: self.address.into(),
1762            public_address: self.public_address.map(|a| a.into()),
1763            expect_proxy: self.expect_proxy.unwrap_or(false),
1764            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1765            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1766            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1767            active: false,
1768            sni_preread_timeout: Some(
1769                self.sni_preread_timeout
1770                    .unwrap_or(DEFAULT_SNI_PREREAD_TIMEOUT),
1771            ),
1772            sni_preread_max_bytes: Some(
1773                self.sni_preread_max_bytes
1774                    .unwrap_or(DEFAULT_SNI_PREREAD_MAX_BYTES),
1775            ),
1776        };
1777
1778        // POST: the built listener binds exactly the requested address and is
1779        // created inactive (activation is a later, explicit step).
1780        debug_assert_eq!(
1781            tcp_listener_config.address,
1782            self.address.into(),
1783            "TCP listener must bind the requested address"
1784        );
1785        debug_assert!(
1786            !tcp_listener_config.active,
1787            "a freshly built TCP listener must start inactive"
1788        );
1789        Ok(tcp_listener_config)
1790    }
1791
1792    /// build a UDP listener. UDP has no `expect_proxy` / `connect_timeout`;
1793    /// flows are keyed by 4-tuple and torn down on idle.
1794    ///
1795    /// Timeouts: an unset `front_timeout` / `back_timeout` falls back to the
1796    /// UDP-specific defaults ([`DEFAULT_UDP_FRONT_TIMEOUT`] /
1797    /// [`DEFAULT_UDP_BACK_TIMEOUT`], both 30 s) — *not* the global HTTP/TCP
1798    /// `front_timeout` (60 s) / `back_timeout`. This keeps the effective
1799    /// default in lock-step with the CLI help and the proto
1800    /// `UdpListenerConfig` defaults (both 30 s). The global config is consulted
1801    /// only for `buffer_size` (the `max_rx_datagram_size` cap).
1802    ///
1803    /// Validation:
1804    /// * `max_rx_datagram_size` is clamped to the effective `buffer_size`
1805    ///   (with a warning) so a datagram can never exceed the pool buffer.
1806    /// * an explicit non-zero `max_flows` that exceeds ~70% of the soft
1807    ///   `RLIMIT_NOFILE` emits a warning (the per-flow connected sockets
1808    ///   would otherwise risk EMFILE).
1809    pub fn to_udp(&mut self, config: Option<&Config>) -> Result<UdpListenerConfig, ConfigError> {
1810        if self.protocol != Some(ListenerProtocol::Udp) {
1811            return Err(ConfigError::WrongListenerProtocol {
1812                expected: ListenerProtocol::Udp,
1813                found: self.protocol.to_owned(),
1814            });
1815        }
1816        self.reject_client_auth_fields("UDP")?;
1817
1818        let mut max_rx_datagram_size = self
1819            .max_rx_datagram_size
1820            .unwrap_or(DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE);
1821        let buffer_size = config.map(|c| c.buffer_size).unwrap_or(DEFAULT_BUFFER_SIZE);
1822        if u64::from(max_rx_datagram_size) > buffer_size {
1823            warn!(
1824                "UDP listener {}: max_rx_datagram_size = {} exceeds buffer_size = {}, clamping to buffer_size",
1825                self.address, max_rx_datagram_size, buffer_size
1826            );
1827            max_rx_datagram_size = buffer_size as u32;
1828        }
1829
1830        let max_flows = self.max_flows.unwrap_or(DEFAULT_UDP_MAX_FLOWS);
1831        if max_flows > 0
1832            && let Some(soft_limit) = soft_rlimit_nofile()
1833        {
1834            let advisory = soft_limit.saturating_mul(7) / 10;
1835            if u64::from(max_flows) > advisory {
1836                warn!(
1837                    "UDP listener {}: max_flows = {} exceeds ~70% of the soft RLIMIT_NOFILE ({}); \
1838                         per-flow connected sockets may hit EMFILE",
1839                    self.address, max_flows, advisory
1840                );
1841            }
1842        }
1843
1844        Ok(UdpListenerConfig {
1845            interface: self.validated_interface()?,
1846            address: self.address.into(),
1847            public_address: self.public_address.map(|a| a.into()),
1848            front_timeout: self.front_timeout.unwrap_or(DEFAULT_UDP_FRONT_TIMEOUT),
1849            back_timeout: self.back_timeout.unwrap_or(DEFAULT_UDP_BACK_TIMEOUT),
1850            max_rx_datagram_size,
1851            max_flows,
1852            active: false,
1853        })
1854    }
1855}
1856
1857/// Read the soft `RLIMIT_NOFILE` (max open file descriptors). Used as an
1858/// advisory ceiling for `max_flows` on UDP listeners. Returns `None` when
1859/// the limit cannot be read so callers skip the advisory check rather than
1860/// failing config-load.
1861fn soft_rlimit_nofile() -> Option<u64> {
1862    let mut limit = libc::rlimit {
1863        rlim_cur: 0,
1864        rlim_max: 0,
1865    };
1866    // SAFETY: `getrlimit` writes into the provided `rlimit` out-parameter and
1867    // does not retain the pointer. A non-zero return means failure, in which
1868    // case we ignore the (uninitialised-by-contract) value and return None.
1869    let rc = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) };
1870    if rc == 0 {
1871        // `rlim_cur` is `rlim_t`, which is `u64` on the Tier-1 targets sozu
1872        // builds for, so it already matches the `Option<u64>` return type.
1873        Some(limit.rlim_cur)
1874    } else {
1875        None
1876    }
1877}
1878
1879/// read a custom HTTP answer from a file
1880fn read_http_answer_file(path: &Option<String>) -> Result<Option<String>, ConfigError> {
1881    match path {
1882        Some(path) => {
1883            let mut content = String::new();
1884            let mut file = File::open(path).map_err(|io_error| ConfigError::FileOpen {
1885                path_to_open: path.to_owned(),
1886                io_error,
1887            })?;
1888
1889            file.read_to_string(&mut content)
1890                .map_err(|io_error| ConfigError::FileRead {
1891                    path_to_read: path.to_owned(),
1892                    io_error,
1893                })?;
1894
1895            Ok(Some(content))
1896        }
1897        None => Ok(None),
1898    }
1899}
1900
1901/// Resolve a single `answers` map entry into the literal template body
1902/// the proto layer expects.
1903///
1904/// The same resolution rule applies to entries at every layer:
1905/// * **Listener-level** `[listeners.<id>.answers]` — the global default
1906///   that fires whenever no more specific override matches.
1907/// * **Cluster-level** `[clusters.<id>.answers]` — overrides the
1908///   listener-level default for the matching status code on requests
1909///   routed to that cluster.
1910///
1911/// Two source forms are accepted:
1912/// * **Filesystem path** — the value starts with the `file://` URI
1913///   scheme. Everything after the prefix is treated as a path; the
1914///   path is opened and read into a string. Mirrors the on-disk
1915///   loading the per-status `read_http_answer_file` helper performs
1916///   for the deprecated `answer_301`..`answer_507` fields.
1917/// * **Inline literal** (default) — anything else. The value is taken
1918///   verbatim as the template body, including an empty string (a
1919///   0-byte response payload, typical with `Connection: close` and no
1920///   headers). The bare-string default keeps the common case — a
1921///   short canned response — typing-light; operators who need a file
1922///   say so explicitly with `file://`.
1923pub fn resolve_answer_source(value: &str) -> Result<String, ConfigError> {
1924    if let Some(path) = value.strip_prefix("file://") {
1925        let mut content = String::new();
1926        let mut file = File::open(path).map_err(|io_error| ConfigError::FileOpen {
1927            path_to_open: path.to_owned(),
1928            io_error,
1929        })?;
1930        file.read_to_string(&mut content)
1931            .map_err(|io_error| ConfigError::FileRead {
1932                path_to_read: path.to_owned(),
1933                io_error,
1934            })?;
1935        return Ok(content);
1936    }
1937    Ok(value.to_owned())
1938}
1939
1940/// Load every per-status template referenced by `answers`.
1941///
1942/// `answers` maps an HTTP status code (e.g. `"503"`) to either a
1943/// filesystem path or an `inline:<body>` literal — see
1944/// [`resolve_answer_source`] for the resolution rules. Each entry is
1945/// resolved into a body string and inserted into the returned map
1946/// under the same key, ready to be assigned to the proto-level
1947/// `answers` field on a [`HttpListenerConfig`] / [`HttpsListenerConfig`]
1948/// / [`Cluster`]. Empty values are skipped (treated as "preserve
1949/// current") so the caller can use them as a no-op stub in example
1950/// configs.
1951///
1952/// Errors map to the existing `ConfigError::FileOpen` /
1953/// `ConfigError::FileRead` variants so the operator gets the same
1954/// diagnostics whether the path comes from this map or from the
1955/// deprecated per-status `answer_301`..`answer_507` fields.
1956pub fn load_answers(
1957    answers: &BTreeMap<String, String>,
1958) -> Result<BTreeMap<String, String>, ConfigError> {
1959    let mut out = BTreeMap::new();
1960    for (code, value) in answers {
1961        if value.is_empty() {
1962            continue;
1963        }
1964        out.insert(code.to_owned(), resolve_answer_source(value)?);
1965    }
1966    // POST: the loaded map never invents a status code (every output key is an
1967    // input key) and never grows past the input — empty-valued entries are
1968    // skipped, so |out| <= |answers|.
1969    debug_assert!(
1970        out.len() <= answers.len(),
1971        "load_answers must not synthesize entries"
1972    );
1973    debug_assert!(
1974        out.keys().all(|k| answers.contains_key(k)),
1975        "every loaded status code must come from the input map"
1976    );
1977    Ok(out)
1978}
1979
1980/// Cardinality knob for metrics labels in the StatsD network drain.
1981///
1982/// Mirrors HAProxy's `process|frontend|backend|server` extra-counters opt-in.
1983/// Operators choose the lowest level that satisfies their dashboards so that
1984/// the keyspace stays bounded. Each level is a SUPERSET of the previous one:
1985///
1986/// - `process` — proxy-only counters (no listener, cluster, or backend label).
1987/// - `frontend` — adds per-listener (frontend) breakdown.
1988/// - `cluster` — adds per-cluster aggregation. **Default** (preserves the
1989///   pre-knob behaviour).
1990/// - `backend` — adds per-backend aggregation (cluster + backend, highest
1991///   cardinality).
1992#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
1993#[serde(rename_all = "lowercase")]
1994#[derive(Default)]
1995pub enum MetricDetailLevel {
1996    Process,
1997    Frontend,
1998    #[default]
1999    Cluster,
2000    Backend,
2001}
2002
2003impl From<MetricDetailLevel> for MetricDetail {
2004    fn from(level: MetricDetailLevel) -> Self {
2005        match level {
2006            MetricDetailLevel::Process => MetricDetail::DetailProcess,
2007            MetricDetailLevel::Frontend => MetricDetail::DetailFrontend,
2008            MetricDetailLevel::Cluster => MetricDetail::DetailCluster,
2009            MetricDetailLevel::Backend => MetricDetail::DetailBackend,
2010        }
2011    }
2012}
2013
2014impl From<MetricDetail> for MetricDetailLevel {
2015    /// Reverse of [`From<MetricDetailLevel> for MetricDetail`] — used by the
2016    /// worker side to convert the protobuf wire enum back into the
2017    /// configuration enum before passing it to `sozu_lib::metrics::setup`.
2018    fn from(detail: MetricDetail) -> Self {
2019        match detail {
2020            MetricDetail::DetailProcess => MetricDetailLevel::Process,
2021            MetricDetail::DetailFrontend => MetricDetailLevel::Frontend,
2022            MetricDetail::DetailCluster => MetricDetailLevel::Cluster,
2023            MetricDetail::DetailBackend => MetricDetailLevel::Backend,
2024        }
2025    }
2026}
2027
2028/// The forwarding header family an HTTP or HTTPS listener adds to every
2029/// forwarded request, as spelled in the TOML configuration
2030/// (`forwarded_headers = "both"`). The wire form is [`ForwardedHeaders`].
2031///
2032/// - `both` — **default**, the historical behaviour: `X-Forwarded-For`,
2033///   `X-Forwarded-Proto`, `X-Forwarded-Port` and RFC 7239 `Forwarded`, a
2034///   client-supplied chain extended with this hop.
2035/// - `x_forwarded` — only the `X-Forwarded-*` family, handled as in `both`;
2036///   a client-supplied `Forwarded` passes through untouched.
2037/// - `rfc7239` — only `Forwarded` (RFC 7239 §4), a client-supplied chain
2038///   extended with this hop; a client-supplied `X-Forwarded-For`,
2039///   `X-Forwarded-Proto`, `X-Forwarded-Port` or `X-Forwarded-Host` is removed.
2040/// - `none` — no forwarding header is added and client-supplied ones pass
2041///   through untouched.
2042#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
2043#[serde(rename_all = "snake_case")]
2044pub enum ForwardedHeadersMode {
2045    #[default]
2046    Both,
2047    XForwarded,
2048    Rfc7239,
2049    None,
2050}
2051
2052impl From<ForwardedHeadersMode> for ForwardedHeaders {
2053    fn from(mode: ForwardedHeadersMode) -> Self {
2054        match mode {
2055            ForwardedHeadersMode::Both => ForwardedHeaders::Both,
2056            ForwardedHeadersMode::XForwarded => ForwardedHeaders::XForwarded,
2057            ForwardedHeadersMode::Rfc7239 => ForwardedHeaders::Rfc7239,
2058            ForwardedHeadersMode::None => ForwardedHeaders::None,
2059        }
2060    }
2061}
2062
2063impl From<ForwardedHeaders> for ForwardedHeadersMode {
2064    fn from(headers: ForwardedHeaders) -> Self {
2065        match headers {
2066            ForwardedHeaders::Both => ForwardedHeadersMode::Both,
2067            ForwardedHeaders::XForwarded => ForwardedHeadersMode::XForwarded,
2068            ForwardedHeaders::Rfc7239 => ForwardedHeadersMode::Rfc7239,
2069            ForwardedHeaders::None => ForwardedHeadersMode::None,
2070        }
2071    }
2072}
2073
2074#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2075#[serde(deny_unknown_fields)]
2076pub struct MetricsConfig {
2077    pub address: SocketAddr,
2078    #[serde(default)]
2079    pub tagged_metrics: bool,
2080    #[serde(default)]
2081    pub prefix: Option<String>,
2082    /// Cardinality knob for label-aware metrics. Defaults to `cluster` to
2083    /// preserve historical behaviour. See [`MetricDetailLevel`].
2084    #[serde(default)]
2085    pub detail: MetricDetailLevel,
2086}
2087
2088#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2089#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2090#[serde(deny_unknown_fields)]
2091pub enum PathRuleType {
2092    Prefix,
2093    Regex,
2094    Equals,
2095}
2096
2097#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2098#[serde(deny_unknown_fields)]
2099pub struct FileClusterFrontendConfig {
2100    pub address: SocketAddr,
2101    pub hostname: Option<String>,
2102    /// TCP frontend only (sozu-proxy/sozu#1279): ALPN protocol names to
2103    /// match, read from the TLS ClientHello during the same preread that
2104    /// resolves `hostname` (mapped to the wire `sni` field for TCP
2105    /// frontends). Empty (the default) is the catch-all for this
2106    /// frontend's `hostname`/SNI on its listener. Rejected on HTTP/HTTPS
2107    /// frontends — ALPN there is negotiated by the listener's
2108    /// `alpn_protocols`, not per-frontend.
2109    #[serde(default)]
2110    pub alpn: Vec<String>,
2111    /// creates a path routing rule where the request URL path has to match this
2112    pub path: Option<String>,
2113    /// declares whether the path rule is Prefix (default), Regex, or Equals
2114    pub path_type: Option<PathRuleType>,
2115    pub method: Option<String>,
2116    pub certificate: Option<String>,
2117    pub key: Option<String>,
2118    pub certificate_chain: Option<String>,
2119    #[serde(default)]
2120    pub tls_versions: Vec<TlsVersion>,
2121    #[serde(default)]
2122    pub position: RulePosition,
2123    pub tags: Option<BTreeMap<String, String>>,
2124    /// Frontend-level redirect policy. Accepted values are `forward`
2125    /// (default — route to the backend), `permanent` (return 301 with the
2126    /// computed `Location`), or `unauthorized` (return 401 with
2127    /// `WWW-Authenticate: Basic realm=…`). Case-insensitive.
2128    pub redirect: Option<String>,
2129    /// Scheme used when emitting a permanent redirect's `Location`. Accepted
2130    /// values are `use-same` (default — preserve request scheme), `use-http`,
2131    /// `use-https`. Case-insensitive.
2132    pub redirect_scheme: Option<String>,
2133    /// Optional template applied to the emitted permanent-redirect response
2134    /// body. Supports the `%REDIRECT_LOCATION` and other variables
2135    /// documented in `doc/configure.md`.
2136    pub redirect_template: Option<String>,
2137    /// Rewrite host template. Supports `$HOST[n]` / `$PATH[n]` placeholders
2138    /// populated from regex captures collected during routing.
2139    pub rewrite_host: Option<String>,
2140    /// Rewrite path template. Same grammar as `rewrite_host`.
2141    pub rewrite_path: Option<String>,
2142    /// Optional literal port override on the rewritten URL.
2143    pub rewrite_port: Option<u32>,
2144    /// When true, requests routed through this frontend must carry a valid
2145    /// `Authorization: Basic <user:pass>` header whose hash matches one of
2146    /// the cluster's `authorized_hashes`. Default: false.
2147    pub required_auth: Option<bool>,
2148    /// Header mutations applied to requests and/or responses passing through
2149    /// this frontend. See [`HeaderEditConfig`] for the empty-value-deletes
2150    /// semantics (HAProxy `del-header` parity).
2151    pub headers: Option<Vec<HeaderEditConfig>>,
2152    /// Per-frontend HSTS (RFC 6797) policy. When set, overrides any
2153    /// listener-default HSTS for this frontend. Set `enabled = false`
2154    /// to suppress an inherited listener default. Per RFC 6797 §7.2,
2155    /// HSTS is rejected on plain-HTTP frontends at config-load time.
2156    pub hsts: Option<FileHstsConfig>,
2157}
2158
2159/// A single header mutation as serialised under
2160/// `[[clusters.<id>.frontends.headers]]`. Maps to the proto [`Header`]
2161/// message at request-build time.
2162///
2163/// `position` accepts `request`, `response`, or `both` (case-insensitive).
2164/// An empty `value` deletes the header by name (HAProxy `del-header` parity).
2165#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2166#[serde(deny_unknown_fields)]
2167pub struct HeaderEditConfig {
2168    pub position: String,
2169    pub key: String,
2170    pub value: String,
2171}
2172
2173/// HSTS (HTTP Strict Transport Security, RFC 6797) policy as serialised
2174/// under `[https.listeners.default.hsts]` (listener default) or
2175/// `[clusters.<id>.frontends.hsts]` (per-frontend override).
2176///
2177/// `enabled` is REQUIRED whenever the block is present — its presence vs
2178/// absence disambiguates "preserve current" / "explicit disable" / "enable"
2179/// on hot-reconfig partial updates.
2180///
2181/// When `enabled = true` and `max_age` is omitted, sozu substitutes
2182/// [`DEFAULT_HSTS_MAX_AGE`] (1 year) at config-load time.
2183#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2184#[serde(deny_unknown_fields)]
2185pub struct FileHstsConfig {
2186    /// REQUIRED. `true` enables HSTS for this scope; `false` suppresses
2187    /// any inherited listener default (explicit-disable signal).
2188    pub enabled: Option<bool>,
2189    /// `Strict-Transport-Security: max-age=<seconds>`. Optional —
2190    /// defaults to [`DEFAULT_HSTS_MAX_AGE`] when `enabled = true`.
2191    /// `max_age = 0` is the RFC 6797 §11.4 kill switch and is allowed
2192    /// silently; `0 < max_age < 86400` warns at config-load.
2193    pub max_age: Option<u32>,
2194    /// Append `; includeSubDomains` to the rendered header.
2195    pub include_subdomains: Option<bool>,
2196    /// Append `; preload` to the rendered header. Opt-in only — see RFC
2197    /// 6797 §14.2 and <https://hstspreload.org/>.
2198    pub preload: Option<bool>,
2199    /// Operator opt-in to override any backend-supplied
2200    /// `Strict-Transport-Security` header. RFC 6797 §6.1 default
2201    /// behaviour is to PRESERVE the backend's value (sozu's edit uses
2202    /// `HeaderEditMode::SetIfAbsent`). Set this to `true` to harden a
2203    /// stale or weak upstream HSTS policy centrally — the materialiser
2204    /// then uses `HeaderEditMode::Set`, replacing any backend STS with
2205    /// sozu's rendered value.
2206    pub force_replace_backend: Option<bool>,
2207}
2208
2209impl FileHstsConfig {
2210    /// Validate and convert the file-level [`FileHstsConfig`] into the
2211    /// proto [`HstsConfig`]. `scope` is a human-readable string (e.g.
2212    /// "listener" or "frontend api/example.com") surfaced into errors
2213    /// and warnings so the operator can pinpoint the offending block.
2214    ///
2215    /// Validation:
2216    /// - `enabled` is required when any other field is set
2217    ///   (`HstsEnabledRequired`); the parser returns the typed error so
2218    ///   callers can fail fast.
2219    /// - `enabled = true && max_age = None` substitutes
2220    ///   [`DEFAULT_HSTS_MAX_AGE`].
2221    /// - `0 < max_age < 86400` warns (likely misconfig — sub-day max-age
2222    ///   is useful only for testing).
2223    /// - `preload = true` with `max_age < DEFAULT_HSTS_MAX_AGE` or
2224    ///   `include_subdomains != Some(true)` warns (the Chrome HSTS
2225    ///   preload list will reject the host).
2226    /// - `max_age = 0` is allowed silently (RFC 6797 §11.4 kill switch).
2227    pub fn to_proto(&self, scope: &str) -> Result<HstsConfig, ConfigError> {
2228        let enabled = match self.enabled {
2229            Some(v) => v,
2230            None => return Err(ConfigError::HstsEnabledRequired(scope.to_owned())),
2231        };
2232
2233        let max_age = match (enabled, self.max_age) {
2234            (true, None) => Some(DEFAULT_HSTS_MAX_AGE),
2235            (_, m) => m,
2236        };
2237
2238        if let Some(value) = max_age
2239            && value > 0
2240            && value < 86_400
2241        {
2242            warn!(
2243                "HSTS max_age = {}s on {} is below 1 day — this is almost certainly a \
2244                 misconfiguration. RFC 6797 §11.4 reserves max_age = 0 as the explicit kill \
2245                 switch.",
2246                value, scope
2247            );
2248        }
2249
2250        let include_subdomains = self.include_subdomains;
2251        let preload = self.preload;
2252
2253        if matches!(preload, Some(true)) {
2254            let max_age_value = max_age.unwrap_or(0);
2255            if max_age_value < DEFAULT_HSTS_MAX_AGE {
2256                warn!(
2257                    "HSTS preload = true on {} with max_age = {}s; the Chrome HSTS preload \
2258                     list requires max_age >= {} (https://hstspreload.org/).",
2259                    scope, max_age_value, DEFAULT_HSTS_MAX_AGE
2260                );
2261            }
2262            if include_subdomains != Some(true) {
2263                warn!(
2264                    "HSTS preload = true on {} without include_subdomains = true; the Chrome \
2265                     HSTS preload list requires includeSubDomains \
2266                     (https://hstspreload.org/).",
2267                    scope
2268                );
2269            }
2270        }
2271
2272        let config = HstsConfig {
2273            enabled: Some(enabled),
2274            max_age,
2275            include_subdomains,
2276            preload,
2277            force_replace_backend: self.force_replace_backend,
2278        };
2279
2280        // POST: a built HstsConfig always records an explicit `enabled` flag
2281        // (the `None` case errored above), and an enabled policy always carries
2282        // a max_age — defaulted to DEFAULT_HSTS_MAX_AGE when the operator left
2283        // it unset, so the worker never emits an `max-age`-less STS header.
2284        debug_assert_eq!(
2285            config.enabled,
2286            Some(enabled),
2287            "built HSTS config must record the resolved enabled flag"
2288        );
2289        debug_assert!(
2290            !enabled || config.max_age.is_some(),
2291            "an enabled HSTS policy must carry a max_age"
2292        );
2293        Ok(config)
2294    }
2295}
2296
2297impl FileClusterFrontendConfig {
2298    pub fn to_tcp_front(&self) -> Result<TcpFrontendConfig, ConfigError> {
2299        if self.path.is_some() {
2300            return Err(ConfigError::InvalidFrontendConfig(
2301                "path_prefix".to_string(),
2302            ));
2303        }
2304        if self.certificate.is_some() {
2305            return Err(ConfigError::InvalidFrontendConfig(
2306                "certificate".to_string(),
2307            ));
2308        }
2309        if self.certificate_chain.is_some() {
2310            return Err(ConfigError::InvalidFrontendConfig(
2311                "certificate_chain".to_string(),
2312            ));
2313        }
2314
2315        // `hostname` maps to the TCP frontend's `sni` (sozu-proxy/sozu#1279):
2316        // an SNI-scoped frontend reads the same TOML key HTTP frontends use
2317        // for their routing hostname, validated for the TCP-specific
2318        // exact-or-single-wildcard shape.
2319        let sni = match &self.hostname {
2320            Some(hostname) => Some(validate_sni_pattern(hostname)?),
2321            None => None,
2322        };
2323
2324        // `alpn` only ever gets consulted from within the SNI-scoped
2325        // preread route table (sozu-proxy/sozu#1279 hardening): a frontend
2326        // with no `hostname`/`sni` installs the worker's raw no-SNI
2327        // catch-all path instead, which never looks at `alpn` at all. Left
2328        // unchecked, this would load fine and then silently never enforce
2329        // the configured protocol list.
2330        if sni.is_none() && !self.alpn.is_empty() {
2331            return Err(ConfigError::AlpnWithoutSni {
2332                address: self.address,
2333            });
2334        }
2335
2336        let tcp_front = TcpFrontendConfig {
2337            address: self.address,
2338            tags: self.tags.clone(),
2339            sni,
2340            alpn: self.alpn.clone(),
2341            // Resolved against `known_addresses` in `populate_clusters`; a
2342            // bare `to_tcp_front` (no listener context) defaults to TCP.
2343            udp: false,
2344        };
2345        // POST: a TCP frontend binds exactly the requested address and carries
2346        // no HTTP-only attributes — the guards above reject path / certificate,
2347        // so an Ok return is a witness that none leaked through (an L7
2348        // attribute on an L4 frontend is a config-shape violation). `hostname`
2349        // is intentionally excluded from this witness: it is consumed above
2350        // into `sni`, not rejected.
2351        debug_assert_eq!(
2352            tcp_front.address, self.address,
2353            "TCP frontend must bind the requested address"
2354        );
2355        debug_assert!(
2356            self.path.is_none() && self.certificate.is_none() && self.certificate_chain.is_none(),
2357            "a built TCP frontend must carry no HTTP-only attributes"
2358        );
2359        debug_assert!(
2360            tcp_front.sni.is_some() || tcp_front.alpn.is_empty(),
2361            "a built TCP frontend without sni must never carry a non-empty alpn"
2362        );
2363        Ok(tcp_front)
2364    }
2365
2366    pub fn to_http_front(&self, _cluster_id: &str) -> Result<HttpFrontendConfig, ConfigError> {
2367        if !self.alpn.is_empty() {
2368            return Err(ConfigError::InvalidFrontendConfig("alpn".to_string()));
2369        }
2370
2371        let hostname = match &self.hostname {
2372            Some(hostname) => hostname.to_owned(),
2373            None => {
2374                return Err(ConfigError::Missing(MissingKind::Field(
2375                    "hostname".to_string(),
2376                )));
2377            }
2378        };
2379
2380        let key_opt = match self.key.as_ref() {
2381            None => None,
2382            Some(path) => {
2383                let key = Config::load_file(path)?;
2384                Some(key)
2385            }
2386        };
2387
2388        let certificate_opt = match self.certificate.as_ref() {
2389            None => None,
2390            Some(path) => {
2391                let certificate = Config::load_file(path)?;
2392                Some(certificate)
2393            }
2394        };
2395
2396        let certificate_chain = match self.certificate_chain.as_ref() {
2397            None => None,
2398            Some(path) => {
2399                let certificate_chain = Config::load_file(path)?;
2400                Some(split_certificate_chain(certificate_chain))
2401            }
2402        };
2403
2404        let path = match (self.path.as_ref(), self.path_type.as_ref()) {
2405            (None, _) => PathRule::prefix("".to_string()),
2406            (Some(s), Some(PathRuleType::Prefix)) => PathRule::prefix(s.to_string()),
2407            (Some(s), Some(PathRuleType::Regex)) => PathRule::regex(s.to_string()),
2408            (Some(s), Some(PathRuleType::Equals)) => PathRule::equals(s.to_string()),
2409            (Some(s), None) => PathRule::prefix(s.clone()),
2410        };
2411
2412        let redirect = match self.redirect.as_deref() {
2413            Some(v) => Some(parse_redirect_policy(v)?),
2414            None => None,
2415        };
2416        let redirect_scheme = match self.redirect_scheme.as_deref() {
2417            Some(v) => Some(parse_redirect_scheme(v)?),
2418            None => None,
2419        };
2420
2421        let headers = match self.headers.as_ref() {
2422            Some(entries) => {
2423                let mut out = Vec::with_capacity(entries.len());
2424                for (index, entry) in entries.iter().enumerate() {
2425                    out.push(parse_header_edit(index, entry)?);
2426                }
2427                out
2428            }
2429            None => Vec::new(),
2430        };
2431
2432        // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on
2433        // plaintext-HTTP responses. A frontend without a key+certificate
2434        // pair generates `RequestType::AddHttpFrontend` in
2435        // `HttpFrontendConfig::generate_requests`, so HSTS configured
2436        // there would silently target an HTTP frontend. Reject at
2437        // config-load before the cert-presence branch can consume it.
2438        let frontend_serves_https = key_opt.is_some() && certificate_opt.is_some();
2439        let hsts = match self.hsts.as_ref() {
2440            Some(h) => {
2441                if !frontend_serves_https {
2442                    return Err(ConfigError::HstsOnPlainHttp(format!(
2443                        "frontend {_cluster_id}/{hostname}"
2444                    )));
2445                }
2446                Some(h.to_proto(&format!("frontend {_cluster_id}/{hostname}"))?)
2447            }
2448            None => None,
2449        };
2450
2451        Ok(HttpFrontendConfig {
2452            address: self.address,
2453            hostname,
2454            certificate: certificate_opt,
2455            key: key_opt,
2456            certificate_chain,
2457            tls_versions: self.tls_versions.clone(),
2458            position: self.position,
2459            path,
2460            method: self.method.clone(),
2461            tags: self.tags.clone(),
2462            redirect,
2463            redirect_scheme,
2464            redirect_template: self.redirect_template.clone(),
2465            rewrite_host: self.rewrite_host.clone(),
2466            rewrite_path: self.rewrite_path.clone(),
2467            rewrite_port: self.rewrite_port,
2468            required_auth: self.required_auth,
2469            headers,
2470            hsts,
2471        })
2472    }
2473}
2474
2475/// Validates and normalizes a TCP frontend's SNI pattern
2476/// (sozu-proxy/sozu#1279): either an exact hostname or a single leading
2477/// `*.` wildcard label. Rejects an embedded `*` anywhere else (so
2478/// `*.*.example.com` and `foo.*.com` are both invalid), a bare `*`, any
2479/// empty label (leading/trailing/consecutive dots), and any non-ASCII
2480/// character ([`ConfigError::NonAsciiSniPattern`]) — on-wire SNI is always
2481/// an ASCII A-label (RFC 6066 §3 / IDNA), so a Unicode U-label would load
2482/// fine but never match at runtime, a silent routing failure. Full IDNA/
2483/// punycode transformation at config-load is intentionally out of scope
2484/// (no `idna` dependency in this crate); operators write the A-label form
2485/// directly, consistent with what the HTTP router produces via
2486/// `idna::domain_to_ascii` in `lib/src/router/mod.rs`. ASCII-lowercases
2487/// the accepted pattern for case-insensitive comparison.
2488///
2489/// Public: this is the SINGLE SNI shape validator shared by TOML config
2490/// load (this module) and the worker's request boundary
2491/// (`TcpListener::validate_new_tcp_front` in `lib/src/tcp.rs`), which an
2492/// `AddTcpFrontend` sent directly over the command socket, or a
2493/// `LoadState` replay, can reach without ever going through config-load —
2494/// both call sites must reject the identical set of malformed shapes.
2495pub fn validate_sni_pattern(sni: &str) -> Result<String, ConfigError> {
2496    let invalid = || ConfigError::InvalidSniPattern {
2497        sni: sni.to_string(),
2498    };
2499
2500    if sni.is_empty() {
2501        return Err(invalid());
2502    }
2503
2504    if !sni.is_ascii() {
2505        return Err(ConfigError::NonAsciiSniPattern {
2506            sni: sni.to_string(),
2507        });
2508    }
2509
2510    // Only a single leading "*." wildcard label is accepted; strip it (if
2511    // present) before checking the remainder is otherwise plain and
2512    // non-empty.
2513    let remainder = sni.strip_prefix("*.").unwrap_or(sni);
2514
2515    // `/` is never valid in a hostname — and the SNI route table is a
2516    // `pattern_trie::TrieNode`, whose insert treats a leftmost label wrapped
2517    // in `/.../` as a REGEX segment; letting one through would silently
2518    // widen routing beyond the documented "exact host or one leading `*.`"
2519    // contract. Checking `remainder` covers the whole pattern: the only
2520    // stripped prefix is the literal `*.`, which cannot contain `/`.
2521    if remainder.is_empty() || remainder.contains('*') || remainder.contains('/') {
2522        return Err(invalid());
2523    }
2524    if remainder.split('.').any(|label| label.is_empty()) {
2525        return Err(invalid());
2526    }
2527
2528    let normalized = sni.to_ascii_lowercase();
2529    // POST: the normalized pattern carries exactly one '*' (the leading
2530    // wildcard marker) or none at all — never more, since the checks above
2531    // reject any '*' in the remainder — and is pure ASCII, since non-ASCII
2532    // input was rejected before normalization (a non-ASCII pattern can
2533    // never match the ASCII A-label SNI on the wire).
2534    debug_assert!(
2535        normalized.matches('*').count() <= 1,
2536        "a validated SNI pattern must carry at most one wildcard marker"
2537    );
2538    debug_assert!(
2539        normalized.is_ascii(),
2540        "a validated SNI pattern must be pure ASCII"
2541    );
2542    Ok(normalized)
2543}
2544
2545/// Parse a `redirect` TOML value (case-insensitive) into the proto enum.
2546pub(crate) fn parse_redirect_policy(value: &str) -> Result<RedirectPolicy, ConfigError> {
2547    match value.to_ascii_lowercase().as_str() {
2548        "forward" => Ok(RedirectPolicy::Forward),
2549        "permanent" => Ok(RedirectPolicy::Permanent),
2550        "unauthorized" => Ok(RedirectPolicy::Unauthorized),
2551        _ => Err(ConfigError::InvalidRedirectPolicy(value.to_owned())),
2552    }
2553}
2554
2555/// Parse a `redirect_scheme` TOML value (case-insensitive) into the proto enum.
2556pub(crate) fn parse_redirect_scheme(value: &str) -> Result<RedirectScheme, ConfigError> {
2557    match value.to_ascii_lowercase().as_str() {
2558        "use-same" | "use_same" => Ok(RedirectScheme::UseSame),
2559        "use-http" | "use_http" => Ok(RedirectScheme::UseHttp),
2560        "use-https" | "use_https" => Ok(RedirectScheme::UseHttps),
2561        _ => Err(ConfigError::InvalidRedirectScheme(value.to_owned())),
2562    }
2563}
2564
2565/// Parse a `[[clusters.<id>.frontends.headers]]` entry into the proto
2566/// [`Header`] message. `index` is the zero-based position of `entry` in
2567/// the source array — surfaced into the error so a multi-entry config
2568/// pinpoints the bad row instead of just naming the unknown position.
2569/// An empty `value` is the HAProxy `del-header` parity (deletes the
2570/// header by name); the proto carries the empty string verbatim.
2571pub(crate) fn parse_header_edit(
2572    index: usize,
2573    entry: &HeaderEditConfig,
2574) -> Result<Header, ConfigError> {
2575    let position = match entry.position.to_ascii_lowercase().as_str() {
2576        "request" => HeaderPosition::Request,
2577        "response" => HeaderPosition::Response,
2578        "both" => HeaderPosition::Both,
2579        _ => {
2580            return Err(ConfigError::InvalidHeaderPosition {
2581                index,
2582                position: entry.position.clone(),
2583            });
2584        }
2585    };
2586    if !header_name_is_valid_token(entry.key.as_bytes()) {
2587        return Err(ConfigError::InvalidHeaderBytes {
2588            index,
2589            field: "key",
2590        });
2591    }
2592    if header_value_contains_forbidden_controls(entry.value.as_bytes()) {
2593        return Err(ConfigError::InvalidHeaderBytes {
2594            index,
2595            field: "value",
2596        });
2597    }
2598    let header = Header {
2599        position: position as i32,
2600        key: entry.key.clone(),
2601        val: entry.value.clone(),
2602    };
2603    // POST: a Header that escapes this function carries a key that is a valid
2604    // RFC 9110 token and a value free of the forbidden control bytes — the two
2605    // guards above are the sole gate, so an emitted Header can never inject a
2606    // CRLF or a bad token onto the H1 wire (mirrors the runtime filter in
2607    // mux/converter.rs).
2608    debug_assert!(
2609        header_name_is_valid_token(header.key.as_bytes()),
2610        "an emitted header key must be a valid token"
2611    );
2612    debug_assert!(
2613        !header_value_contains_forbidden_controls(header.val.as_bytes()),
2614        "an emitted header value must be free of forbidden control bytes"
2615    );
2616    Ok(header)
2617}
2618
2619/// Field names follow the RFC 9110 §5.1 `token` grammar: non-empty,
2620/// composed of `tchar` bytes (alphanumeric plus a closed punctuation
2621/// list). HTAB and SP are NOT tchar — they belong to field-value
2622/// grammar and must be rejected in the name. Reusing the more
2623/// permissive value-side filter would let `Host\t` slip through and
2624/// produce an invalid header line on the H1 wire (security review
2625/// LISA-002 follow-up).
2626pub(crate) fn header_name_is_valid_token(bytes: &[u8]) -> bool {
2627    if bytes.is_empty() {
2628        return false;
2629    }
2630    bytes.iter().all(|&b| is_tchar(b))
2631}
2632
2633/// `tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
2634/// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA` per RFC 9110 §5.6.2.
2635fn is_tchar(b: u8) -> bool {
2636    b.is_ascii_alphanumeric()
2637        || matches!(
2638            b,
2639            b'!' | b'#'
2640                | b'$'
2641                | b'%'
2642                | b'&'
2643                | b'\''
2644                | b'*'
2645                | b'+'
2646                | b'-'
2647                | b'.'
2648                | b'^'
2649                | b'_'
2650                | b'`'
2651                | b'|'
2652                | b'~'
2653        )
2654}
2655
2656/// Reject any byte that would let a header injection escape the value
2657/// block on the wire (RFC 9110 §5.5 / RFC 9113 §8.2.1):
2658/// `\0..=\x08`, `\x0A..=\x1F`, and `\x7F` — the entire C0 control set
2659/// minus horizontal tab `\x09`, which RFC 9110 explicitly permits in
2660/// field values. Mirrors the runtime filter at
2661/// `lib/src/protocol/mux/converter.rs::call` so config-load and runtime
2662/// agree on which header values may travel.
2663pub(crate) fn header_value_contains_forbidden_controls(bytes: &[u8]) -> bool {
2664    bytes
2665        .iter()
2666        .any(|&b| matches!(b, 0x00..=0x08 | 0x0A..=0x1F | 0x7F))
2667}
2668
2669#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2670#[serde(deny_unknown_fields, rename_all = "lowercase")]
2671pub enum ListenerProtocol {
2672    Http,
2673    Https,
2674    Tcp,
2675    Udp,
2676}
2677
2678#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2679#[serde(deny_unknown_fields, rename_all = "lowercase")]
2680pub enum FileClusterProtocolConfig {
2681    Http,
2682    Tcp,
2683}
2684
2685fn default_health_check_interval() -> u32 {
2686    10
2687}
2688fn default_health_check_timeout() -> u32 {
2689    5
2690}
2691fn default_health_check_threshold() -> u32 {
2692    3
2693}
2694fn default_health_check_uri() -> String {
2695    "/".to_owned()
2696}
2697
2698#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2699#[serde(deny_unknown_fields)]
2700pub struct FileHealthCheckConfig {
2701    /// Request target of the HTTP probe. Defaults to `/`; ignored in `TCP`
2702    /// mode.
2703    #[serde(default = "default_health_check_uri")]
2704    pub uri: String,
2705    #[serde(default = "default_health_check_interval")]
2706    pub interval: u32,
2707    #[serde(default = "default_health_check_timeout")]
2708    pub timeout: u32,
2709    #[serde(default = "default_health_check_threshold")]
2710    pub healthy_threshold: u32,
2711    #[serde(default = "default_health_check_threshold")]
2712    pub unhealthy_threshold: u32,
2713    #[serde(default)]
2714    pub expected_status: u32,
2715    /// Probe mode, parsed in SCREAMING_SNAKE_CASE: `"HTTP"` (the default)
2716    /// or `"TCP"`.
2717    #[serde(default)]
2718    pub mode: Option<HealthCheckMode>,
2719    /// HTTP mode: statuses that count as healthy, each one of `"200"`,
2720    /// `"200-399"`, `"2xx"` or `"any"` (see [`parse_http_status_range`]).
2721    #[serde(default)]
2722    pub accepted_statuses: Vec<String>,
2723}
2724
2725impl FileHealthCheckConfig {
2726    /// Build the proto config, parsing `accepted_statuses` and running
2727    /// [`validate_health_check_config`] so a bad block is refused at load
2728    /// time with the cluster it belongs to.
2729    pub fn to_proto(&self) -> Result<HealthCheckConfig, &'static str> {
2730        let accepted_statuses = self
2731            .accepted_statuses
2732            .iter()
2733            .map(|spec| parse_http_status_range(spec))
2734            .collect::<Result<Vec<_>, _>>()?;
2735        let proto = HealthCheckConfig {
2736            uri: self.uri.to_owned(),
2737            interval: self.interval,
2738            timeout: self.timeout,
2739            healthy_threshold: self.healthy_threshold,
2740            unhealthy_threshold: self.unhealthy_threshold,
2741            expected_status: self.expected_status,
2742            mode: self.mode.unwrap_or(HealthCheckMode::Http) as i32,
2743            accepted_statuses,
2744        };
2745        // POST: the proto mirrors the file config exactly — the URI and all
2746        // timing knobs are carried through verbatim (no clamping or defaulting
2747        // happens at this layer; defaults are applied by serde at parse time).
2748        debug_assert_eq!(proto.uri, self.uri, "proto URI must mirror the file config");
2749        debug_assert!(
2750            proto.interval == self.interval
2751                && proto.timeout == self.timeout
2752                && proto.healthy_threshold == self.healthy_threshold
2753                && proto.unhealthy_threshold == self.unhealthy_threshold,
2754            "proto timing knobs must mirror the file config"
2755        );
2756        debug_assert_eq!(
2757            proto.accepted_statuses.len(),
2758            self.accepted_statuses.len(),
2759            "every accepted status spec must map to exactly one range"
2760        );
2761        validate_health_check_config(&proto)?;
2762        Ok(proto)
2763    }
2764}
2765
2766/// Lowest and highest valid HTTP status codes: RFC 9110 §15 calls values
2767/// outside `100..=599` invalid.
2768pub const HTTP_STATUS_MIN: u32 = 100;
2769pub const HTTP_STATUS_MAX: u32 = 599;
2770
2771/// Parse one `accepted_statuses` entry of the TOML file or the CLI:
2772///
2773/// - `"any"`: every valid status, `100-599` — any well-formed HTTP response;
2774/// - `"Nxx"` with `N` in `1..=5`: the class `N00-N99`;
2775/// - `"A-B"`: the inclusive range `A..=B`;
2776/// - `"A"`: the single status `A`.
2777///
2778/// Bounds must lie in `100..=599` and `A <= B`.
2779pub fn parse_http_status_range(spec: &str) -> Result<HttpStatusRange, &'static str> {
2780    const INVALID: &str = "health check accepted status must be a code (\"404\"), a range \
2781                           (\"200-399\"), a class (\"2xx\") or \"any\"";
2782    let spec = spec.trim();
2783    let range = if spec.eq_ignore_ascii_case("any") {
2784        HttpStatusRange {
2785            start: HTTP_STATUS_MIN,
2786            end: HTTP_STATUS_MAX,
2787        }
2788    } else if let [class @ b'1'..=b'5', x1, x2] = spec.as_bytes()
2789        && x1.eq_ignore_ascii_case(&b'x')
2790        && x2.eq_ignore_ascii_case(&b'x')
2791    {
2792        let start = u32::from(class - b'0') * 100;
2793        HttpStatusRange {
2794            start,
2795            end: start + 99,
2796        }
2797    } else if let Some((start, end)) = spec.split_once('-') {
2798        HttpStatusRange {
2799            start: parse_status_code(start).ok_or(INVALID)?,
2800            end: parse_status_code(end).ok_or(INVALID)?,
2801        }
2802    } else {
2803        let code = parse_status_code(spec).ok_or(INVALID)?;
2804        HttpStatusRange {
2805            start: code,
2806            end: code,
2807        }
2808    };
2809    validate_http_status_range(&range)?;
2810    Ok(range)
2811}
2812
2813/// A status code is ASCII digits only: `u32::from_str` alone would also take
2814/// a leading `+`.
2815fn parse_status_code(code: &str) -> Option<u32> {
2816    let code = code.trim();
2817    if code.is_empty() || !code.bytes().all(|b| b.is_ascii_digit()) {
2818        return None;
2819    }
2820    code.parse().ok()
2821}
2822
2823fn validate_http_status_range(range: &HttpStatusRange) -> Result<(), &'static str> {
2824    if range.start < HTTP_STATUS_MIN || range.end > HTTP_STATUS_MAX {
2825        return Err("health check accepted statuses must lie within 100-599");
2826    }
2827    if range.start > range.end {
2828        return Err("health check accepted status range must not end before it starts");
2829    }
2830    debug_assert!(
2831        (HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&range.start)
2832            && (HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&range.end),
2833        "a validated range must lie within the valid status codes"
2834    );
2835    Ok(())
2836}
2837
2838/// Validate a cluster's client affinity key source, `affinity_header` or
2839/// `affinity_cookie`.
2840///
2841/// At most one of the two may be set, and the one that is must be an RFC 9110
2842/// `token` (a cookie name is a `token` too, RFC 6265 §4.1.1): a name no
2843/// request can carry would silently key every request on its source IP.
2844///
2845/// Two header names are refused although they are tokens, because the key
2846/// lookup can never find them among the request's headers: `Host`, which the
2847/// H1 parser folds into the request authority and elides (H2 carries it as
2848/// `:authority`), and `Cookie`, whose crumbs both parsers move into the cookie
2849/// jar — key on one cookie with `affinity_cookie` instead. Names compare
2850/// case-insensitively, as header names do.
2851///
2852/// Called on every `AddCluster`, so a TOML load, a reload, a saved state and
2853/// a direct API request are held to the same rule as `sozu cluster add`.
2854pub fn validate_affinity_key(
2855    affinity_header: Option<&str>,
2856    affinity_cookie: Option<&str>,
2857) -> Result<(), &'static str> {
2858    match (affinity_header, affinity_cookie) {
2859        (Some(_), Some(_)) => {
2860            Err("affinity_header and affinity_cookie are mutually exclusive: set at most one")
2861        }
2862        (Some(name), None) if !header_name_is_valid_token(name.as_bytes()) => {
2863            Err("affinity_header must be a non-empty RFC 9110 token (a header field name)")
2864        }
2865        (Some(name), None) if name.eq_ignore_ascii_case("host") => Err(
2866            "affinity_header cannot be Host: the request authority is not a header Sōzu keeps \
2867             (H1 folds Host into it, H2 sends :authority)",
2868        ),
2869        (Some(name), None) if name.eq_ignore_ascii_case("cookie") => Err(
2870            "affinity_header cannot be Cookie: cookies are parsed into a jar; key on one with \
2871             affinity_cookie instead",
2872        ),
2873        (None, Some(name)) if !header_name_is_valid_token(name.as_bytes()) => {
2874            Err("affinity_cookie must be a non-empty RFC 6265 cookie name (an RFC 9110 token)")
2875        }
2876        _ => Ok(()),
2877    }
2878}
2879
2880/// Default `shard_min_backends`: the reference fleet of *Workload isolation
2881/// using shuffle sharding* (Colm MacCárthaigh, Amazon Builders' Library,
2882/// 2019), eight workers in shards of two. Below it the combinations are too
2883/// few to isolate anything: with four backends in shards of two, six shards
2884/// exist and nearly every pair of clients overlaps.
2885pub const DEFAULT_SHARD_MIN_BACKENDS: u32 = 8;
2886
2887/// Validate a cluster's shuffle-sharding knobs (sozu-proxy/sozu#524).
2888///
2889/// `shard_percent` turns sharding on and must lie in `1..=100`;
2890/// `shard_min_backends` must be at least 2, the smallest shard; and
2891/// `shard_min_backends` or `shard_mode` without `shard_percent` is refused
2892/// rather than silently inert. Called at config load and on every
2893/// `AddCluster`.
2894pub fn validate_shuffle_sharding(
2895    shard_percent: Option<u32>,
2896    shard_min_backends: Option<u32>,
2897    shard_mode: Option<i32>,
2898) -> Result<(), &'static str> {
2899    let Some(percent) = shard_percent else {
2900        if shard_min_backends.is_some() || shard_mode.is_some() {
2901            return Err("shard_min_backends and shard_mode require shard_percent");
2902        }
2903        return Ok(());
2904    };
2905    if !(1..=100).contains(&percent) {
2906        return Err("shard_percent must lie in 1..=100");
2907    }
2908    if shard_min_backends.is_some_and(|minimum| minimum < 2) {
2909        return Err("shard_min_backends must be at least 2, the smallest shard");
2910    }
2911    if shard_mode.is_some_and(|mode| ShardMode::try_from(mode).is_err()) {
2912        return Err("shard_mode must be FALLBACK or STRICT");
2913    }
2914    Ok(())
2915}
2916
2917/// Validate a [`HealthCheckConfig`] for the rules every layer relies on:
2918/// a known probe mode, strict positive thresholds, a URI that cannot
2919/// smuggle a second HTTP message on the wire (RFC 9110 §5.1 —
2920/// request-target; the leading `/` is only required in HTTP mode, the
2921/// only one that sends it), accepted status ranges within `100..=599`,
2922/// at most one of `expected_status` / `accepted_statuses`, and neither in
2923/// TCP mode, which judges no status. Used by
2924/// the CLI request builder and the worker `SetHealthCheck` handler so
2925/// off-channel inputs (TOML reload, third-party clients) are
2926/// constrained the same way as `sozu cluster health-check set`.
2927///
2928/// The function is intentionally [`Result<(), &'static str>`] rather
2929/// than carrying a structured error: the diagnostics only flow into
2930/// CLI output / worker error responses where the message is the value.
2931pub fn validate_health_check_config(cfg: &HealthCheckConfig) -> Result<(), &'static str> {
2932    let Ok(mode) = HealthCheckMode::try_from(cfg.mode) else {
2933        return Err("health check mode must be HTTP or TCP");
2934    };
2935    if cfg.interval == 0 {
2936        return Err("health check interval must be > 0");
2937    }
2938    if cfg.timeout == 0 {
2939        return Err("health check timeout must be > 0");
2940    }
2941    if cfg.healthy_threshold == 0 {
2942        return Err("health check healthy_threshold must be > 0");
2943    }
2944    if cfg.unhealthy_threshold == 0 {
2945        return Err("health check unhealthy_threshold must be > 0");
2946    }
2947    // The URI only reaches the wire in HTTP mode; TCP mode ignores it and
2948    // may leave it empty. The control-byte check below still applies, so a
2949    // stored config never carries a URI that would smuggle a second message
2950    // if displayed or later reused.
2951    if mode == HealthCheckMode::Http && !cfg.uri.starts_with('/') {
2952        return Err("health check URI must start with '/'");
2953    }
2954    if cfg
2955        .uri
2956        .bytes()
2957        .any(|b| b == b'\r' || b == b'\n' || b == 0 || (b < 0x20 && b != b'\t'))
2958    {
2959        return Err("health check URI must not contain CR, LF, NUL, or other C0 control bytes");
2960    }
2961    for range in &cfg.accepted_statuses {
2962        validate_http_status_range(range)?;
2963    }
2964    if cfg.expected_status != 0 && !cfg.accepted_statuses.is_empty() {
2965        return Err(
2966            "health check expected_status and accepted_statuses are mutually exclusive: \
2967             set at most one",
2968        );
2969    }
2970    // A TCP probe judges no status: a status field set beside it would read
2971    // as a constraint the probe silently does not apply.
2972    if mode == HealthCheckMode::Tcp
2973        && (cfg.expected_status != 0 || !cfg.accepted_statuses.is_empty())
2974    {
2975        return Err(
2976            "health check expected_status and accepted_statuses apply to HTTP mode only: \
2977             leave them unset with mode TCP",
2978        );
2979    }
2980    // POST: a validated config has strictly-positive timing knobs (a zero
2981    // interval/timeout/threshold would make the health-check loop spin or
2982    // never converge) and a request-target the worker can splice into an HTTP
2983    // probe without smuggling a second message. Every Ok return is a witness
2984    // for all of these.
2985    debug_assert!(
2986        cfg.interval > 0
2987            && cfg.timeout > 0
2988            && cfg.healthy_threshold > 0
2989            && cfg.unhealthy_threshold > 0,
2990        "validated health-check thresholds must all be strictly positive"
2991    );
2992    debug_assert!(
2993        mode == HealthCheckMode::Tcp || cfg.uri.starts_with('/'),
2994        "a validated HTTP health-check URI must be an absolute path"
2995    );
2996    debug_assert!(
2997        cfg.expected_status == 0 || cfg.accepted_statuses.is_empty(),
2998        "a validated health check sets at most one of expected_status and accepted_statuses"
2999    );
3000    debug_assert!(
3001        mode == HealthCheckMode::Http
3002            || (cfg.expected_status == 0 && cfg.accepted_statuses.is_empty()),
3003        "a validated TCP health check sets no status field"
3004    );
3005    Ok(())
3006}
3007
3008#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3009#[serde(deny_unknown_fields)]
3010pub struct FileClusterConfig {
3011    pub frontends: Vec<FileClusterFrontendConfig>,
3012    pub backends: Vec<BackendConfig>,
3013    pub protocol: FileClusterProtocolConfig,
3014    pub sticky_session: Option<bool>,
3015    pub https_redirect: Option<bool>,
3016    #[serde(default)]
3017    pub send_proxy: Option<bool>,
3018    #[serde(default)]
3019    pub load_balancing: LoadBalancingAlgorithms,
3020    pub answer_503: Option<String>,
3021    #[serde(default)]
3022    pub load_metric: Option<LoadMetric>,
3023    /// Backend-capability hint: `true` when the backend speaks HTTP/2 (h2c or h2+TLS once #1218 lands).
3024    /// Does NOT gate H2 at the frontend — frontend H2 is ALPN-negotiated independently (see `alpn_protocols`).
3025    pub http2: Option<bool>,
3026    /// Per-cluster HTTP answer template overrides keyed by HTTP status
3027    /// code (e.g. `"503"`). Each value is either a filesystem path or an
3028    /// `inline:<body>` literal — see [`resolve_answer_source`]. Loaded
3029    /// into [`Cluster::answers`] at build time via [`load_answers`].
3030    ///
3031    /// Layering: an entry here overrides the listener-level
3032    /// `[listeners.<id>.answers]` default for the matching status on
3033    /// requests routed to this cluster. The listener-level map is the
3034    /// global default; the cluster-level map is the per-cluster
3035    /// override.
3036    pub answers: Option<BTreeMap<String, String>>,
3037    /// Optional explicit port to use when building the `Location` header
3038    /// for an `https_redirect`. When unset, the listener's effective HTTPS
3039    /// port is used. Lets operators front a non-standard HTTPS port (e.g.
3040    /// 8443) on the redirect target while keeping `https_redirect = true`.
3041    pub https_redirect_port: Option<u32>,
3042    /// Authorized credentials for HTTP basic authentication, formatted as
3043    /// `username:hex(sha256(password))` (lower-case hex). Empty list
3044    /// disables auth even when a frontend sets `required_auth = true` —
3045    /// such requests are rejected with 401.
3046    pub authorized_hashes: Option<Vec<String>>,
3047    /// Realm string emitted in `WWW-Authenticate: Basic realm="…"` when
3048    /// an unauthenticated request is rejected. Treated as an opaque
3049    /// value (no template substitution).
3050    pub www_authenticate: Option<String>,
3051    /// Override the global per-(cluster, source-IP) connection limit for
3052    /// this cluster. `None` (field absent) inherits the global default
3053    /// `max_connections_per_ip`. `Some(0)` is explicit "unlimited for
3054    /// this cluster". `Some(n > 0)` overrides with the cluster-specific
3055    /// limit. The source IP is taken from the parsed proxy-protocol
3056    /// header when present, else `peer_addr`. On a UDP cluster it limits
3057    /// concurrent flows per datagram source address; UDP never inherits the
3058    /// global default.
3059    pub max_connections_per_ip: Option<u64>,
3060    /// Override the global per-(cluster, source-SUBNET) connection limit
3061    /// for this cluster. Same three-state semantics as
3062    /// [`FileClusterConfig::max_connections_per_ip`]: `None` inherits,
3063    /// `Some(0)` is explicit unlimited, `Some(n > 0)` overrides. This is
3064    /// an independent counter — a connection must satisfy BOTH caps.
3065    /// The mask itself is global and boot-time only (`subnet_ipv4_prefix`
3066    /// / `subnet_ipv6_prefix`), not overridable per cluster.
3067    pub max_connections_per_subnet: Option<u64>,
3068    /// Override the global `Retry-After` header value (seconds) emitted
3069    /// on HTTP 429 responses for this cluster. `None` inherits the global
3070    /// default. `Some(0)` omits the header. TCP clusters carry this
3071    /// field for shape uniformity but never emit the header (no HTTP
3072    /// envelope).
3073    pub retry_after: Option<u32>,
3074    /// Optional HTTP health-check configuration. The probe wire format
3075    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
3076    /// (h2c) when true.
3077    #[serde(default)]
3078    pub health_check: Option<FileHealthCheckConfig>,
3079    /// Optional UDP-specific cluster configuration, parsed from a
3080    /// `[clusters.<id>.udp]` block. Additive: clusters without a `udp`
3081    /// block produce `udp: None` on the resulting [`Cluster`].
3082    #[serde(default)]
3083    pub udp: Option<FileUdpClusterConfig>,
3084    /// Key an HTTP/HTTPS cluster's `HRW`/`MAGLEV` selection on the value of
3085    /// this request header instead of the client's source IP. A request
3086    /// without the header falls back to its source IP. Mutually exclusive
3087    /// with `affinity_cookie`; refused on a TCP cluster, which has no request
3088    /// headers and always keys on the source IP.
3089    #[serde(default)]
3090    pub affinity_header: Option<String>,
3091    /// Same as `affinity_header`, on the value of this request cookie.
3092    #[serde(default)]
3093    pub affinity_cookie: Option<String>,
3094    /// Shuffle sharding: restrict each client to a shard of
3095    /// `max(2, ceil(shard_percent × N / 100))` of the cluster's N primary
3096    /// backends, the top of the rendezvous ranking of its affinity key.
3097    /// Off when unset. See [`validate_shuffle_sharding`].
3098    #[serde(default)]
3099    pub shard_percent: Option<u32>,
3100    /// Sharding applies only from this many primary backends; defaults to
3101    /// [`DEFAULT_SHARD_MIN_BACKENDS`].
3102    #[serde(default)]
3103    pub shard_min_backends: Option<u32>,
3104    /// `FALLBACK` (default) spills an exhausted shard over to the rest of the
3105    /// cluster; `STRICT` refuses.
3106    #[serde(default)]
3107    pub shard_mode: Option<ShardMode>,
3108    /// Override the global `max_connection_attempts` for this cluster: how
3109    /// many backend connections a request may try, the first included,
3110    /// before it is answered `503` (HTTP) or closed (TCP). `None` inherits
3111    /// the global value. See [`validate_max_connection_attempts`].
3112    #[serde(default)]
3113    pub max_connection_attempts: Option<u32>,
3114}
3115
3116/// UDP backend health-check configuration, parsed from
3117/// `[clusters.<id>.udp.health]`.
3118#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3119#[serde(deny_unknown_fields)]
3120pub struct FileUdpHealthConfig {
3121    /// probe mode, parsed in SCREAMING_SNAKE_CASE: `"HEALTH_OFF"`,
3122    /// `"TCP_PROBE"`, or `"UDP_PROBE"`. Defaults to `TCP_PROBE` when a
3123    /// `udp.health` block is present.
3124    pub mode: Option<UdpHealthMode>,
3125    pub tcp_port: Option<u32>,
3126    pub rise: Option<u32>,
3127    pub fall: Option<u32>,
3128    pub fail_open: Option<bool>,
3129    /// hex-free literal payload sent for a UDP probe.
3130    pub udp_probe_payload: Option<String>,
3131    pub probe_interval_seconds: Option<u32>,
3132    pub probe_timeout_seconds: Option<u32>,
3133}
3134
3135impl FileUdpHealthConfig {
3136    pub fn to_proto(&self) -> UdpHealthConfig {
3137        UdpHealthConfig {
3138            mode: self.mode.map(|m| m as i32),
3139            tcp_port: self.tcp_port,
3140            rise: self.rise,
3141            fall: self.fall,
3142            fail_open: self.fail_open,
3143            udp_probe_payload: self
3144                .udp_probe_payload
3145                .as_ref()
3146                .map(|p| p.as_bytes().to_owned()),
3147            probe_interval_seconds: self.probe_interval_seconds,
3148            probe_timeout_seconds: self.probe_timeout_seconds,
3149        }
3150    }
3151}
3152
3153/// UDP-specific cluster knobs, parsed from a `[clusters.<id>.udp]` block.
3154#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3155#[serde(deny_unknown_fields)]
3156pub struct FileUdpClusterConfig {
3157    /// flow affinity key, parsed in SCREAMING_SNAKE_CASE: `"SOURCE_IP"`
3158    /// (default) or `"SOURCE_IP_PORT"`.
3159    pub affinity_key: Option<UdpAffinityKey>,
3160    /// expected replies per flow; 0 = unlimited.
3161    pub responses: Option<u32>,
3162    /// max client datagrams per flow; 0 = unlimited.
3163    pub requests: Option<u32>,
3164    /// send a PROXY protocol v2 header to the backend.
3165    pub send_proxy_protocol: Option<bool>,
3166    /// prepend PPv2 to every datagram; false = first-datagram only.
3167    pub proxy_protocol_every_datagram: Option<bool>,
3168    /// optional backend health-check configuration.
3169    pub health: Option<FileUdpHealthConfig>,
3170}
3171
3172impl FileUdpClusterConfig {
3173    pub fn to_proto(&self) -> UdpClusterConfig {
3174        UdpClusterConfig {
3175            affinity_key: self.affinity_key.map(|k| k as i32),
3176            responses: self.responses,
3177            requests: self.requests,
3178            send_proxy_protocol: self.send_proxy_protocol,
3179            proxy_protocol_every_datagram: self.proxy_protocol_every_datagram,
3180            health: self.health.as_ref().map(|h| h.to_proto()),
3181        }
3182    }
3183}
3184
3185#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3186#[serde(deny_unknown_fields)]
3187pub struct BackendConfig {
3188    pub address: SocketAddr,
3189    pub weight: Option<u8>,
3190    pub sticky_id: Option<String>,
3191    pub backup: Option<bool>,
3192    pub backend_id: Option<String>,
3193}
3194
3195impl FileClusterConfig {
3196    pub fn to_cluster_config(
3197        self,
3198        cluster_id: &str,
3199        expect_proxy: &HashSet<SocketAddr>,
3200    ) -> Result<ClusterConfig, ConfigError> {
3201        // PRE: every frontend that converts cleanly must survive into the built
3202        // cluster — no frontend is silently dropped during conversion.
3203        let requested_frontend_count = self.frontends.len();
3204        validate_affinity_key(
3205            self.affinity_header.as_deref(),
3206            self.affinity_cookie.as_deref(),
3207        )
3208        .map_err(|reason| ConfigError::InvalidAffinityKey {
3209            cluster_id: cluster_id.to_owned(),
3210            reason,
3211        })?;
3212        validate_shuffle_sharding(
3213            self.shard_percent,
3214            self.shard_min_backends,
3215            self.shard_mode.map(|mode| mode as i32),
3216        )
3217        .map_err(|reason| ConfigError::InvalidShuffleSharding {
3218            cluster_id: cluster_id.to_owned(),
3219            reason,
3220        })?;
3221        if let Some(attempts) = self.max_connection_attempts {
3222            validate_max_connection_attempts(attempts).map_err(|reason| {
3223                ConfigError::InvalidMaxConnectionAttempts {
3224                    cluster_id: Some(cluster_id.to_owned()),
3225                    value: attempts,
3226                    reason,
3227                }
3228            })?;
3229        }
3230        let health_check = self
3231            .health_check
3232            .as_ref()
3233            .map(FileHealthCheckConfig::to_proto)
3234            .transpose()
3235            .map_err(|reason| ConfigError::InvalidHealthCheck {
3236                cluster_id: cluster_id.to_owned(),
3237                reason,
3238            })?;
3239        match self.protocol {
3240            FileClusterProtocolConfig::Tcp => {
3241                // A TCP cluster has no request to read a header or a cookie
3242                // from: it always keys on the source IP. Refused rather than
3243                // ignored, so the file never claims a key the proxy does not use.
3244                if self.affinity_header.is_some() || self.affinity_cookie.is_some() {
3245                    return Err(ConfigError::InvalidAffinityKey {
3246                        cluster_id: cluster_id.to_owned(),
3247                        reason: "a TCP cluster always keys on the client source IP: \
3248                                 affinity_header and affinity_cookie apply to HTTP clusters only",
3249                    });
3250                }
3251                let mut has_expect_proxy = None;
3252                let mut frontends = Vec::new();
3253                for f in self.frontends {
3254                    if expect_proxy.contains(&f.address) {
3255                        match has_expect_proxy {
3256                            Some(true) => {}
3257                            Some(false) => {
3258                                return Err(ConfigError::Incompatible {
3259                                    object: ObjectKind::Cluster,
3260                                    id: cluster_id.to_owned(),
3261                                    kind: IncompatibilityKind::ProxyProtocol,
3262                                });
3263                            }
3264                            None => has_expect_proxy = Some(true),
3265                        }
3266                    } else {
3267                        match has_expect_proxy {
3268                            Some(false) => {}
3269                            Some(true) => {
3270                                return Err(ConfigError::Incompatible {
3271                                    object: ObjectKind::Cluster,
3272                                    id: cluster_id.to_owned(),
3273                                    kind: IncompatibilityKind::ProxyProtocol,
3274                                });
3275                            }
3276                            None => has_expect_proxy = Some(false),
3277                        }
3278                    }
3279                    let tcp_frontend = f.to_tcp_front()?;
3280                    frontends.push(tcp_frontend);
3281                }
3282
3283                let send_proxy = self.send_proxy.unwrap_or(false);
3284                let expect_proxy = has_expect_proxy.unwrap_or(false);
3285                let proxy_protocol = match (send_proxy, expect_proxy) {
3286                    (true, true) => Some(ProxyProtocolConfig::RelayHeader),
3287                    (true, false) => Some(ProxyProtocolConfig::SendHeader),
3288                    (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
3289                    _ => None,
3290                };
3291
3292                let answers = match self.answers.as_ref() {
3293                    Some(map) => load_answers(map)?,
3294                    None => BTreeMap::new(),
3295                };
3296
3297                let udp = self.udp.as_ref().map(|u| u.to_proto());
3298                // POST: every requested frontend converted (none dropped), and
3299                // the resolved proxy-protocol mode is the documented function of
3300                // the (send, expect) pair — expect-only must never resolve to a
3301                // send-header mode and vice versa, which would corrupt the wire
3302                // framing.
3303                debug_assert_eq!(
3304                    frontends.len(),
3305                    requested_frontend_count,
3306                    "every TCP frontend must survive conversion"
3307                );
3308                debug_assert_eq!(
3309                    proxy_protocol,
3310                    match (send_proxy, expect_proxy) {
3311                        (true, true) => Some(ProxyProtocolConfig::RelayHeader),
3312                        (true, false) => Some(ProxyProtocolConfig::SendHeader),
3313                        (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
3314                        (false, false) => None,
3315                    },
3316                    "proxy_protocol must be the (send, expect) function"
3317                );
3318
3319                Ok(ClusterConfig::Tcp(TcpClusterConfig {
3320                    cluster_id: cluster_id.to_string(),
3321                    frontends,
3322                    backends: self.backends,
3323                    proxy_protocol,
3324                    load_balancing: self.load_balancing,
3325                    load_metric: self.load_metric,
3326                    answers,
3327                    https_redirect_port: self.https_redirect_port,
3328                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
3329                    www_authenticate: self.www_authenticate,
3330                    max_connections_per_ip: self.max_connections_per_ip,
3331                    max_connections_per_subnet: self.max_connections_per_subnet,
3332                    retry_after: self.retry_after,
3333                    health_check,
3334                    udp,
3335                    shard_percent: self.shard_percent,
3336                    shard_min_backends: self.shard_min_backends,
3337                    shard_mode: self.shard_mode,
3338                    max_connection_attempts: self.max_connection_attempts,
3339                }))
3340            }
3341            FileClusterProtocolConfig::Http => {
3342                let mut frontends = Vec::new();
3343                for frontend in self.frontends {
3344                    let http_frontend = frontend.to_http_front(cluster_id)?;
3345                    frontends.push(http_frontend);
3346                }
3347
3348                let answer_503 = self.answer_503.as_ref().and_then(|path| {
3349                    Config::load_file(path)
3350                        .map_err(|e| {
3351                            error!("cannot load 503 error page at path '{}': {:?}", path, e);
3352                            e
3353                        })
3354                        .ok()
3355                });
3356
3357                let answers = match self.answers.as_ref() {
3358                    Some(map) => load_answers(map)?,
3359                    None => BTreeMap::new(),
3360                };
3361
3362                let udp = self.udp.as_ref().map(|u| u.to_proto());
3363                // POST: every requested HTTP frontend converted — none dropped
3364                // (a dropped frontend would silently stop routing a hostname).
3365                debug_assert_eq!(
3366                    frontends.len(),
3367                    requested_frontend_count,
3368                    "every HTTP frontend must survive conversion"
3369                );
3370
3371                Ok(ClusterConfig::Http(HttpClusterConfig {
3372                    cluster_id: cluster_id.to_string(),
3373                    frontends,
3374                    backends: self.backends,
3375                    sticky_session: self.sticky_session.unwrap_or(false),
3376                    https_redirect: self.https_redirect.unwrap_or(false),
3377                    load_balancing: self.load_balancing,
3378                    load_metric: self.load_metric,
3379                    answer_503,
3380                    http2: self.http2,
3381                    answers,
3382                    https_redirect_port: self.https_redirect_port,
3383                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
3384                    www_authenticate: self.www_authenticate,
3385                    max_connections_per_ip: self.max_connections_per_ip,
3386                    max_connections_per_subnet: self.max_connections_per_subnet,
3387                    retry_after: self.retry_after,
3388                    health_check,
3389                    udp,
3390                    affinity_header: self.affinity_header,
3391                    affinity_cookie: self.affinity_cookie,
3392                    shard_percent: self.shard_percent,
3393                    shard_min_backends: self.shard_min_backends,
3394                    shard_mode: self.shard_mode,
3395                    max_connection_attempts: self.max_connection_attempts,
3396                }))
3397            }
3398        }
3399    }
3400}
3401
3402#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3403#[serde(deny_unknown_fields)]
3404pub struct HttpFrontendConfig {
3405    pub address: SocketAddr,
3406    pub hostname: String,
3407    pub path: PathRule,
3408    pub method: Option<String>,
3409    pub certificate: Option<String>,
3410    pub key: Option<String>,
3411    pub certificate_chain: Option<Vec<String>>,
3412    #[serde(default)]
3413    pub tls_versions: Vec<TlsVersion>,
3414    #[serde(default)]
3415    pub position: RulePosition,
3416    pub tags: Option<BTreeMap<String, String>>,
3417    /// Resolved redirect policy. `None` keeps the proto-default `FORWARD`.
3418    #[serde(default)]
3419    pub redirect: Option<RedirectPolicy>,
3420    /// Resolved redirect scheme. `None` keeps the proto-default `USE_SAME`.
3421    #[serde(default)]
3422    pub redirect_scheme: Option<RedirectScheme>,
3423    #[serde(default)]
3424    pub redirect_template: Option<String>,
3425    #[serde(default)]
3426    pub rewrite_host: Option<String>,
3427    #[serde(default)]
3428    pub rewrite_path: Option<String>,
3429    #[serde(default)]
3430    pub rewrite_port: Option<u32>,
3431    #[serde(default)]
3432    pub required_auth: Option<bool>,
3433    /// Header mutations applied to requests and/or responses passing through
3434    /// this frontend. Empty by default.
3435    #[serde(default)]
3436    pub headers: Vec<Header>,
3437    /// Resolved per-frontend HSTS (RFC 6797) policy. `None` means inherit
3438    /// the listener default at frontend-add time in the worker.
3439    #[serde(default)]
3440    pub hsts: Option<HstsConfig>,
3441}
3442
3443impl fmt::Debug for HttpFrontendConfig {
3444    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3445        let certificate = self.certificate.as_ref().map(|_| "[redacted]");
3446        let certificate_len = self.certificate.as_ref().map(String::len);
3447        let key = self.key.as_ref().map(|_| "[redacted]");
3448        let key_len = self.key.as_ref().map(String::len);
3449        let certificate_chain = self.certificate_chain.as_ref().map(|_| "[redacted]");
3450        let certificate_chain_count = self.certificate_chain.as_ref().map(Vec::len);
3451        let certificate_chain_len = self.certificate_chain.as_ref().map(|chain| {
3452            chain
3453                .iter()
3454                .map(String::len)
3455                .fold(0usize, usize::saturating_add)
3456        });
3457        let method_len = self.method.as_ref().map(String::len);
3458        let redirect_template_len = self.redirect_template.as_ref().map(String::len);
3459        let rewrite_host_len = self.rewrite_host.as_ref().map(String::len);
3460        let rewrite_path_len = self.rewrite_path.as_ref().map(String::len);
3461
3462        f.debug_struct("HttpFrontendConfig")
3463            .field("address", &self.address)
3464            .field("hostname_len", &self.hostname.len())
3465            .field("path_kind", &self.path.kind)
3466            .field("path_len", &self.path.value.len())
3467            .field("method_len", &method_len)
3468            .field("certificate", &certificate)
3469            .field("certificate_len", &certificate_len)
3470            .field("key", &key)
3471            .field("key_len", &key_len)
3472            .field("certificate_chain", &certificate_chain)
3473            .field("certificate_chain_count", &certificate_chain_count)
3474            .field("certificate_chain_len", &certificate_chain_len)
3475            .field("tls_versions_count", &self.tls_versions.len())
3476            .field("position", &self.position)
3477            .field(
3478                "tags_count",
3479                &self.tags.as_ref().map(BTreeMap::len).unwrap_or_default(),
3480            )
3481            .field("redirect", &self.redirect)
3482            .field("redirect_scheme", &self.redirect_scheme)
3483            .field("redirect_template_len", &redirect_template_len)
3484            .field("rewrite_host_len", &rewrite_host_len)
3485            .field("rewrite_path_len", &rewrite_path_len)
3486            .field("rewrite_port", &self.rewrite_port)
3487            .field("required_auth", &self.required_auth)
3488            .field("headers_count", &self.headers.len())
3489            .field("hsts", &self.hsts)
3490            .finish()
3491    }
3492}
3493
3494impl HttpFrontendConfig {
3495    pub fn generate_requests(&self, cluster_id: &str) -> Vec<Request> {
3496        let mut v = Vec::new();
3497
3498        let tags = self.tags.clone().unwrap_or_default();
3499
3500        if self.key.is_some() && self.certificate.is_some() {
3501            v.push(
3502                RequestType::AddCertificate(AddCertificate {
3503                    address: self.address.into(),
3504                    certificate: CertificateAndKey {
3505                        key: self.key.clone().unwrap(),
3506                        certificate: self.certificate.clone().unwrap(),
3507                        certificate_chain: self.certificate_chain.clone().unwrap_or_default(),
3508                        versions: self.tls_versions.iter().map(|v| *v as i32).collect(),
3509                        // This field is used to override the certificate subject and san, we should not set it when
3510                        // loading the configuration, as we may provide a wildcard certificate for a specific domain.
3511                        // As a result, we will reject legit traffic for others domains as the certificate resolver will
3512                        // not load twice the same certificate and then do not register the certificate for others domains.
3513                        names: vec![],
3514                    },
3515                    expired_at: None,
3516                })
3517                .into(),
3518            );
3519
3520            v.push(
3521                RequestType::AddHttpsFrontend(RequestHttpFrontend {
3522                    cluster_id: Some(cluster_id.to_string()),
3523                    address: self.address.into(),
3524                    hostname: self.hostname.clone(),
3525                    path: self.path.clone(),
3526                    method: self.method.clone(),
3527                    position: self.position.into(),
3528                    tags,
3529                    redirect: self.redirect.map(|r| r as i32),
3530                    required_auth: self.required_auth,
3531                    redirect_scheme: self.redirect_scheme.map(|s| s as i32),
3532                    redirect_template: self.redirect_template.clone(),
3533                    rewrite_host: self.rewrite_host.clone(),
3534                    rewrite_path: self.rewrite_path.clone(),
3535                    rewrite_port: self.rewrite_port,
3536                    headers: self.headers.clone(),
3537                    hsts: self.hsts,
3538                })
3539                .into(),
3540            );
3541        } else {
3542            //create the front both for HTTP and HTTPS if possible
3543            v.push(
3544                RequestType::AddHttpFrontend(RequestHttpFrontend {
3545                    cluster_id: Some(cluster_id.to_string()),
3546                    address: self.address.into(),
3547                    hostname: self.hostname.clone(),
3548                    path: self.path.clone(),
3549                    method: self.method.clone(),
3550                    position: self.position.into(),
3551                    tags,
3552                    redirect: self.redirect.map(|r| r as i32),
3553                    required_auth: self.required_auth,
3554                    redirect_scheme: self.redirect_scheme.map(|s| s as i32),
3555                    redirect_template: self.redirect_template.clone(),
3556                    rewrite_host: self.rewrite_host.clone(),
3557                    rewrite_path: self.rewrite_path.clone(),
3558                    rewrite_port: self.rewrite_port,
3559                    headers: self.headers.clone(),
3560                    hsts: self.hsts,
3561                })
3562                .into(),
3563            );
3564        }
3565
3566        v
3567    }
3568}
3569
3570#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3571#[serde(deny_unknown_fields)]
3572pub struct HttpClusterConfig {
3573    pub cluster_id: String,
3574    pub frontends: Vec<HttpFrontendConfig>,
3575    pub backends: Vec<BackendConfig>,
3576    pub sticky_session: bool,
3577    pub https_redirect: bool,
3578    pub load_balancing: LoadBalancingAlgorithms,
3579    pub load_metric: Option<LoadMetric>,
3580    pub answer_503: Option<String>,
3581    pub http2: Option<bool>,
3582    /// Per-status template body map (already loaded from disk). Maps to
3583    /// the proto [`Cluster::answers`] field.
3584    #[serde(default)]
3585    pub answers: BTreeMap<String, String>,
3586    #[serde(default)]
3587    pub https_redirect_port: Option<u32>,
3588    #[serde(default)]
3589    pub authorized_hashes: Vec<String>,
3590    #[serde(default)]
3591    pub www_authenticate: Option<String>,
3592    /// Per-cluster override of the global `max_connections_per_ip`. See
3593    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
3594    #[serde(default)]
3595    pub max_connections_per_ip: Option<u64>,
3596    /// Per-cluster override of the global `max_connections_per_subnet`.
3597    /// See [`FileClusterConfig::max_connections_per_subnet`] for
3598    /// semantics: an independent second cap, not a replacement for the
3599    /// per-IP one.
3600    #[serde(default)]
3601    pub max_connections_per_subnet: Option<u64>,
3602    /// Per-cluster override of the global `retry_after` HTTP-429 header
3603    /// value (seconds). See [`FileClusterConfig::retry_after`].
3604    #[serde(default)]
3605    pub retry_after: Option<u32>,
3606    /// Optional HTTP health-check configuration. The probe wire format
3607    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
3608    /// (h2c) when true.
3609    #[serde(default)]
3610    pub health_check: Option<HealthCheckConfig>,
3611    /// Optional UDP-specific cluster configuration. Always `None` for HTTP
3612    /// clusters; carried for shape uniformity with the proto [`Cluster`].
3613    #[serde(default)]
3614    pub udp: Option<UdpClusterConfig>,
3615    /// Request header keying `HRW`/`MAGLEV` selection. See
3616    /// [`FileClusterConfig::affinity_header`].
3617    #[serde(default)]
3618    pub affinity_header: Option<String>,
3619    /// Request cookie keying `HRW`/`MAGLEV` selection. See
3620    /// [`FileClusterConfig::affinity_cookie`].
3621    #[serde(default)]
3622    pub affinity_cookie: Option<String>,
3623    /// Shuffle-sharding share of the backends. See
3624    /// [`FileClusterConfig::shard_percent`].
3625    #[serde(default)]
3626    pub shard_percent: Option<u32>,
3627    /// See [`FileClusterConfig::shard_min_backends`].
3628    #[serde(default)]
3629    pub shard_min_backends: Option<u32>,
3630    /// See [`FileClusterConfig::shard_mode`].
3631    #[serde(default)]
3632    pub shard_mode: Option<ShardMode>,
3633    /// See [`FileClusterConfig::max_connection_attempts`].
3634    #[serde(default)]
3635    pub max_connection_attempts: Option<u32>,
3636}
3637
3638impl HttpClusterConfig {
3639    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
3640        let mut v: Vec<Request> = vec![
3641            RequestType::AddCluster(Cluster {
3642                cluster_id: self.cluster_id.clone(),
3643                sticky_session: self.sticky_session,
3644                https_redirect: self.https_redirect,
3645                proxy_protocol: None,
3646                load_balancing: self.load_balancing as i32,
3647                answer_503: self.answer_503.clone(),
3648                load_metric: self.load_metric.map(|s| s as i32),
3649                http2: self.http2,
3650                answers: self.answers.clone(),
3651                https_redirect_port: self.https_redirect_port,
3652                authorized_hashes: self.authorized_hashes.clone(),
3653                www_authenticate: self.www_authenticate.clone(),
3654                max_connections_per_ip: self.max_connections_per_ip,
3655                max_connections_per_subnet: self.max_connections_per_subnet,
3656                retry_after: self.retry_after,
3657                health_check: self.health_check.clone(),
3658                udp: self.udp.clone(),
3659                affinity_header: self.affinity_header.clone(),
3660                affinity_cookie: self.affinity_cookie.clone(),
3661                shard_percent: self.shard_percent,
3662                shard_min_backends: self.shard_min_backends,
3663                shard_mode: self.shard_mode.map(|mode| mode as i32),
3664                max_connection_attempts: self.max_connection_attempts,
3665            })
3666            .into(),
3667        ];
3668
3669        for frontend in &self.frontends {
3670            let mut orders = frontend.generate_requests(&self.cluster_id);
3671            v.append(&mut orders);
3672        }
3673
3674        for (backend_count, backend) in self.backends.iter().enumerate() {
3675            let load_balancing_parameters = Some(LoadBalancingParams {
3676                weight: backend.weight.unwrap_or(100) as i32,
3677            });
3678
3679            v.push(
3680                RequestType::AddBackend(AddBackend {
3681                    cluster_id: self.cluster_id.clone(),
3682                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
3683                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
3684                    }),
3685                    address: backend.address.into(),
3686                    load_balancing_parameters,
3687                    sticky_id: backend.sticky_id.clone(),
3688                    backup: backend.backup,
3689                })
3690                .into(),
3691            );
3692        }
3693
3694        // POST: the order stream begins with exactly one AddCluster and emits
3695        // exactly one AddBackend per configured backend — a missing AddCluster
3696        // would orphan every backend, and a miscounted backend set would
3697        // silently drop or duplicate a backend registration.
3698        debug_assert!(
3699            matches!(
3700                v.first().and_then(|r| r.request_type.as_ref()),
3701                Some(RequestType::AddCluster(_))
3702            ),
3703            "HTTP cluster orders must lead with an AddCluster"
3704        );
3705        debug_assert_eq!(
3706            v.iter()
3707                .filter(|r| matches!(r.request_type, Some(RequestType::AddBackend(_))))
3708                .count(),
3709            self.backends.len(),
3710            "one AddBackend order per configured backend"
3711        );
3712        Ok(v)
3713    }
3714}
3715
3716#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3717pub struct TcpFrontendConfig {
3718    pub address: SocketAddr,
3719    pub tags: Option<BTreeMap<String, String>>,
3720    /// `true` when this frontend's address resolves to a `protocol = "udp"`
3721    /// listener. Resolved at config-load in `ConfigBuilder::populate_clusters`
3722    /// from `known_addresses`; selects `AddUdpFrontend` over `AddTcpFrontend`
3723    /// in [`TcpClusterConfig::generate_requests`]. A UDP cluster is declared as
3724    /// a `protocol = "tcp"` cluster whose frontends point at UDP listeners and
3725    /// whose datagram knobs live under `[clusters.<id>.udp]`.
3726    #[serde(default)]
3727    pub udp: bool,
3728    /// SNI hostname this frontend matches, validated and normalized by
3729    /// [`FileClusterFrontendConfig::to_tcp_front`] (sozu-proxy/sozu#1279).
3730    /// `None` matches regardless of SNI (a raw-TCP fallback).
3731    #[serde(default)]
3732    pub sni: Option<String>,
3733    /// ALPN protocol names this frontend matches; empty is the catch-all
3734    /// for its `sni` on this listener.
3735    #[serde(default)]
3736    pub alpn: Vec<String>,
3737}
3738
3739#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3740pub struct TcpClusterConfig {
3741    pub cluster_id: String,
3742    pub frontends: Vec<TcpFrontendConfig>,
3743    pub backends: Vec<BackendConfig>,
3744    #[serde(default)]
3745    pub proxy_protocol: Option<ProxyProtocolConfig>,
3746    pub load_balancing: LoadBalancingAlgorithms,
3747    pub load_metric: Option<LoadMetric>,
3748    /// Per-status template body map (already loaded from disk). Even
3749    /// though TCP clusters do not emit HTTP responses, the field is
3750    /// carried for shape uniformity with [`HttpClusterConfig`].
3751    #[serde(default)]
3752    pub answers: BTreeMap<String, String>,
3753    #[serde(default)]
3754    pub https_redirect_port: Option<u32>,
3755    #[serde(default)]
3756    pub authorized_hashes: Vec<String>,
3757    #[serde(default)]
3758    pub www_authenticate: Option<String>,
3759    /// Per-cluster override of the global `max_connections_per_ip`. See
3760    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
3761    #[serde(default)]
3762    pub max_connections_per_ip: Option<u64>,
3763    /// Per-cluster override of the global `max_connections_per_subnet`.
3764    /// See [`FileClusterConfig::max_connections_per_subnet`] for
3765    /// semantics: an independent second cap, not a replacement for the
3766    /// per-IP one.
3767    #[serde(default)]
3768    pub max_connections_per_subnet: Option<u64>,
3769    /// Per-cluster override of the global `retry_after`. TCP listeners
3770    /// never emit `Retry-After`; the field is carried for shape
3771    /// uniformity with [`HttpClusterConfig`].
3772    #[serde(default)]
3773    pub retry_after: Option<u32>,
3774    /// Optional HTTP health-check configuration. TCP clusters carry this
3775    /// field for shape uniformity with [`HttpClusterConfig`]; probes are
3776    /// HTTP/1.1 only and TCP-only backends should leave this absent.
3777    #[serde(default)]
3778    pub health_check: Option<HealthCheckConfig>,
3779    /// Optional UDP-specific cluster configuration, parsed from a
3780    /// `[clusters.<id>.udp]` block on this cluster.
3781    #[serde(default)]
3782    pub udp: Option<UdpClusterConfig>,
3783    /// Shuffle-sharding share of the backends. See
3784    /// [`FileClusterConfig::shard_percent`].
3785    #[serde(default)]
3786    pub shard_percent: Option<u32>,
3787    /// See [`FileClusterConfig::shard_min_backends`].
3788    #[serde(default)]
3789    pub shard_min_backends: Option<u32>,
3790    /// See [`FileClusterConfig::shard_mode`].
3791    #[serde(default)]
3792    pub shard_mode: Option<ShardMode>,
3793    /// See [`FileClusterConfig::max_connection_attempts`].
3794    #[serde(default)]
3795    pub max_connection_attempts: Option<u32>,
3796}
3797
3798impl TcpClusterConfig {
3799    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
3800        let mut v: Vec<Request> = vec![
3801            RequestType::AddCluster(Cluster {
3802                cluster_id: self.cluster_id.clone(),
3803                sticky_session: false,
3804                https_redirect: false,
3805                proxy_protocol: self.proxy_protocol.map(|s| s as i32),
3806                load_balancing: self.load_balancing as i32,
3807                load_metric: self.load_metric.map(|s| s as i32),
3808                answer_503: None,
3809                http2: None,
3810                answers: self.answers.clone(),
3811                https_redirect_port: self.https_redirect_port,
3812                authorized_hashes: self.authorized_hashes.clone(),
3813                www_authenticate: self.www_authenticate.clone(),
3814                max_connections_per_ip: self.max_connections_per_ip,
3815                max_connections_per_subnet: self.max_connections_per_subnet,
3816                retry_after: self.retry_after,
3817                health_check: self.health_check.clone(),
3818                udp: self.udp.clone(),
3819                // A TCP cluster always keys on the source IP.
3820                affinity_header: None,
3821                affinity_cookie: None,
3822                shard_percent: self.shard_percent,
3823                shard_min_backends: self.shard_min_backends,
3824                shard_mode: self.shard_mode.map(|mode| mode as i32),
3825                max_connection_attempts: self.max_connection_attempts,
3826            })
3827            .into(),
3828        ];
3829
3830        for frontend in &self.frontends {
3831            // A frontend whose address resolves to a `protocol = "udp"`
3832            // listener (flagged in `populate_clusters`) is added as a UDP
3833            // frontend; all others stay TCP. Mixed TCP/UDP frontends on the
3834            // same cluster are supported.
3835            if frontend.udp {
3836                v.push(
3837                    RequestType::AddUdpFrontend(RequestUdpFrontend {
3838                        cluster_id: self.cluster_id.clone(),
3839                        address: frontend.address.into(),
3840                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
3841                    })
3842                    .into(),
3843                );
3844            } else {
3845                v.push(
3846                    RequestType::AddTcpFrontend(RequestTcpFrontend {
3847                        cluster_id: self.cluster_id.clone(),
3848                        address: frontend.address.into(),
3849                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
3850                        sni: frontend.sni.clone(),
3851                        alpn: frontend.alpn.clone(),
3852                    })
3853                    .into(),
3854                );
3855            }
3856        }
3857
3858        for (backend_count, backend) in self.backends.iter().enumerate() {
3859            let load_balancing_parameters = Some(LoadBalancingParams {
3860                weight: backend.weight.unwrap_or(100) as i32,
3861            });
3862
3863            v.push(
3864                RequestType::AddBackend(AddBackend {
3865                    cluster_id: self.cluster_id.clone(),
3866                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
3867                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
3868                    }),
3869                    address: backend.address.into(),
3870                    load_balancing_parameters,
3871                    sticky_id: backend.sticky_id.clone(),
3872                    backup: backend.backup,
3873                })
3874                .into(),
3875            );
3876        }
3877
3878        // POST: the order stream leads with one AddCluster and emits exactly
3879        // one AddTcpFrontend per frontend and one AddBackend per backend — the
3880        // worker reconstructs the cluster topology solely from these counts.
3881        debug_assert!(
3882            matches!(
3883                v.first().and_then(|r| r.request_type.as_ref()),
3884                Some(RequestType::AddCluster(_))
3885            ),
3886            "TCP cluster orders must lead with an AddCluster"
3887        );
3888        debug_assert_eq!(
3889            v.iter()
3890                .filter(|r| matches!(
3891                    r.request_type,
3892                    Some(RequestType::AddTcpFrontend(_)) | Some(RequestType::AddUdpFrontend(_))
3893                ))
3894                .count(),
3895            self.frontends.len(),
3896            "one AddTcpFrontend or AddUdpFrontend order per configured frontend"
3897        );
3898        debug_assert_eq!(
3899            v.iter()
3900                .filter(|r| matches!(r.request_type, Some(RequestType::AddBackend(_))))
3901                .count(),
3902            self.backends.len(),
3903            "one AddBackend order per configured backend"
3904        );
3905        Ok(v)
3906    }
3907}
3908
3909#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3910pub enum ClusterConfig {
3911    Http(HttpClusterConfig),
3912    Tcp(TcpClusterConfig),
3913}
3914
3915impl ClusterConfig {
3916    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
3917        match *self {
3918            ClusterConfig::Http(ref http) => http.generate_requests(),
3919            ClusterConfig::Tcp(ref tcp) => tcp.generate_requests(),
3920        }
3921    }
3922}
3923
3924/// Parsed from the TOML config provided by the user.
3925#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
3926pub struct FileConfig {
3927    pub command_socket: Option<String>,
3928    /// Permission bits of the unix command socket, as an octal string such
3929    /// as `"0660"` or `"660"`. `None` keeps [`DEFAULT_COMMAND_SOCKET_MODE`].
3930    /// Parsed by [`parse_command_socket_mode`] when the config is built.
3931    #[serde(default)]
3932    pub command_socket_mode: Option<String>,
3933    pub command_buffer_size: Option<u64>,
3934    pub max_command_buffer_size: Option<u64>,
3935    pub max_connections: Option<usize>,
3936    pub min_buffers: Option<u64>,
3937    pub max_buffers: Option<u64>,
3938    pub buffer_size: Option<u64>,
3939    /// Slab-entries-per-connection multiplier. `None` keeps the compile-time
3940    /// default of 4. Operator-visible escape hatch for fan-out topologies
3941    /// that exceed 4 backends per session — clamped to [2, 32] at load.
3942    #[serde(default)]
3943    pub slab_entries_per_connection: Option<u64>,
3944    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
3945    /// payload accepted by the worker's `mux::auth` module. Caps the
3946    /// per-failed-auth allocation so a hostile peer cannot force the worker
3947    /// to decode arbitrarily large tokens. RFC 7617 imposes no upper bound
3948    /// — defaults to 4096, which is well above the realistic
3949    /// `username:password` shape. Operators running hardened tenants can
3950    /// lower this to e.g. 256 or 512 to bound the allocation tighter.
3951    /// Values >= `buffer_size / 3` emit a warning at config-load time
3952    /// (the credential cap shouldn't dominate the per-frontend buffer).
3953    #[serde(default)]
3954    pub basic_auth_max_credential_bytes: Option<u64>,
3955    /// Default per-(cluster, source-IP) connection limit. `None` keeps
3956    /// `0` (unlimited). Each cluster may override via its own
3957    /// `max_connections_per_ip`. The source IP is taken from the parsed
3958    /// proxy-protocol header when present, else `peer_addr`. When the
3959    /// limit is reached, HTTP requests are answered with `429 Too Many
3960    /// Requests` (with optional `Retry-After`) and TCP sessions are
3961    /// closed gracefully without dialing the backend.
3962    #[serde(default)]
3963    pub max_connections_per_ip: Option<u64>,
3964    /// Default per-(cluster, source-SUBNET) connection limit. `None`
3965    /// keeps `0` (unlimited), which leaves this limiter inert and the
3966    /// per-IP behaviour strictly unchanged. It is a SECOND, independent
3967    /// counter beside `max_connections_per_ip`: the per-IP cap serves
3968    /// the API-gateway case, the per-subnet cap the denial-of-service
3969    /// case where an attacker holding a routed prefix takes a fresh
3970    /// address per connection (sozu-proxy/sozu#1270). Both gates are
3971    /// consulted and both must admit, so "10 per IP AND 100 per /64" is
3972    /// expressible. Each cluster may override via its own
3973    /// `max_connections_per_subnet`.
3974    #[serde(default)]
3975    pub max_connections_per_subnet: Option<u64>,
3976    /// IPv4 prefix length (bits, 0-32) used to derive the subnet key.
3977    /// `None` keeps 32, which masks nothing. `/24` is the recommended
3978    /// starting point. Out-of-range values are rejected at config-load
3979    /// time rather than clamped.
3980    #[serde(default)]
3981    pub subnet_ipv4_prefix: Option<u32>,
3982    /// IPv6 prefix length (bits, 0-128) used to derive the subnet key.
3983    /// `None` keeps 128, which masks nothing. `/56` is the recommended
3984    /// starting point. An IPv4-mapped IPv6 source is canonicalised to
3985    /// IPv4 first and masked with `subnet_ipv4_prefix` instead.
3986    #[serde(default)]
3987    pub subnet_ipv6_prefix: Option<u32>,
3988    /// Default `Retry-After` header value (seconds) sent on HTTP 429
3989    /// responses. `Some(0)` or `None` keeping the default `0` omits the
3990    /// header (rendering `Retry-After: 0` invites an immediate retry that
3991    /// defeats the limit). Per-cluster overrides apply for HTTP listeners
3992    /// only. TCP listeners ignore this value (no HTTP envelope).
3993    #[serde(default)]
3994    pub retry_after: Option<u32>,
3995    /// Backend connection attempt budget of every request: how many backend
3996    /// connections a request may try, the first included, before it is
3997    /// answered `503` (HTTP) or closed (TCP). `None` keeps
3998    /// [`DEFAULT_MAX_CONNECTION_ATTEMPTS`]. Each cluster may override it with
3999    /// its own `max_connection_attempts`. Out-of-range values (see
4000    /// [`validate_max_connection_attempts`]) are rejected at config load.
4001    #[serde(default)]
4002    pub max_connection_attempts: Option<u32>,
4003    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
4004    /// zero-copy direction (Linux only, `splice` feature). `None` keeps
4005    /// the kernel default (64 KiB). Applied via `fcntl(F_SETPIPE_SZ)`;
4006    /// the kernel rounds up to a page boundary and clamps at
4007    /// `/proc/sys/fs/pipe-max-size` (default 1 MiB unprivileged). The
4008    /// realised capacity is read back via `fcntl(F_GETPIPE_SZ)` and
4009    /// drives the per-call `len` for `splice_in`. Ignored on non-Linux
4010    /// targets and on builds without the `splice` feature.
4011    #[serde(default)]
4012    pub splice_pipe_capacity_bytes: Option<u64>,
4013    /// Optional UID allowlist for command-socket requests. `None` (default)
4014    /// preserves historical behaviour: any same-UID local process can
4015    /// invoke any verb. When set, requests whose `SO_PEERCRED` UID is not
4016    /// in the list are rejected. Use to restrict mutating verbs to a
4017    /// specific operator UID even when other same-UID daemons coexist
4018    /// (CI runners, monitoring).
4019    #[serde(default)]
4020    pub command_allowed_uids: Option<Vec<u32>>,
4021    pub saved_state: Option<String>,
4022    #[serde(default)]
4023    pub automatic_state_save: Option<bool>,
4024    pub log_level: Option<String>,
4025    pub log_target: Option<String>,
4026    #[serde(default)]
4027    pub log_colored: bool,
4028    /// Dedicated file path for the control-plane audit log. When set, every
4029    /// emitted `[AUDIT]` / `Command(...)` line is also appended to this file
4030    /// opened `O_APPEND | O_CREAT` with mode `0o640` (owner read+write,
4031    /// group read, world nothing) so operators can separate the audit trail
4032    /// from the main log stream and protect it with group-scoped ACLs /
4033    /// logrotate. Independent of the standard `log_target`. `None` keeps
4034    /// audit lines routed only through the standard logger.
4035    #[serde(default)]
4036    pub audit_logs_target: Option<String>,
4037    /// Dedicated file path for a JSON-encoded mirror of the audit log.
4038    /// One JSON object per line so SIEM pipelines (Wazuh, Elastic, Loki)
4039    /// ingest without bespoke parsers. Same `O_APPEND | O_CREAT | 0o640`
4040    /// as `audit_logs_target`. `None` disables the JSON mirror.
4041    #[serde(default)]
4042    pub audit_logs_json_target: Option<String>,
4043    #[serde(default)]
4044    pub access_logs_target: Option<String>,
4045    #[serde(default)]
4046    pub access_logs_format: Option<AccessLogFormat>,
4047    #[serde(default)]
4048    pub access_logs_colored: Option<bool>,
4049    pub worker_count: Option<u16>,
4050    pub worker_automatic_restart: Option<bool>,
4051    pub metrics: Option<MetricsConfig>,
4052    pub disable_cluster_metrics: Option<bool>,
4053    pub listeners: Option<Vec<ListenerBuilder>>,
4054    pub clusters: Option<HashMap<String, FileClusterConfig>>,
4055    pub handle_process_affinity: Option<bool>,
4056    pub ctl_command_timeout: Option<u64>,
4057    pub pid_file_path: Option<String>,
4058    pub activate_listeners: Option<bool>,
4059    #[serde(default)]
4060    pub front_timeout: Option<u32>,
4061    #[serde(default)]
4062    pub back_timeout: Option<u32>,
4063    #[serde(default)]
4064    pub connect_timeout: Option<u32>,
4065    #[serde(default)]
4066    pub zombie_check_interval: Option<u32>,
4067    #[serde(default)]
4068    pub accept_queue_timeout: Option<u32>,
4069    #[serde(default)]
4070    pub evict_on_queue_full: Option<bool>,
4071    #[serde(default)]
4072    pub request_timeout: Option<u32>,
4073    #[serde(default)]
4074    pub worker_timeout: Option<u32>,
4075}
4076
4077impl FileConfig {
4078    pub fn load_from_path(path: &str) -> Result<FileConfig, ConfigError> {
4079        let data = Config::load_file(path)?;
4080
4081        let config: FileConfig = match toml::from_str(&data) {
4082            Ok(config) => config,
4083            Err(e) => {
4084                display_toml_error(&data, &e);
4085                return Err(ConfigError::DeserializeToml(e.to_string()));
4086            }
4087        };
4088
4089        let mut reserved_address: HashSet<ListenerKey> = HashSet::new();
4090
4091        if let Some(listeners) = config.listeners.as_ref() {
4092            for listener in listeners.iter() {
4093                let key = ListenerKey::new(listener.address, listener.interface.as_deref());
4094                if reserved_address.contains(&key) {
4095                    return Err(ConfigError::ListenerAlreadyDeclared(key));
4096                }
4097                reserved_address.insert(key);
4098            }
4099        }
4100
4101        //FIXME: verify how clusters and listeners share addresses
4102        /*
4103        if let Some(ref clusters) = config.clusters {
4104          for (key, cluster) in clusters.iter() {
4105            if let (Some(address), Some(port)) = (cluster.ip_address.clone(), cluster.port) {
4106              let addr = (address, port);
4107              if reserved_address.contains(&addr) {
4108                println!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
4109                  key, addr.0, addr.1);
4110                return Err(Error::new(
4111                  ErrorKind::InvalidData,
4112                  format!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
4113                    key, addr.0, addr.1)));
4114              } else {
4115                reserved_address.insert(addr.clone());
4116              }
4117            }
4118          }
4119        }
4120        */
4121
4122        Ok(config)
4123    }
4124}
4125
4126/// A builder that converts [FileConfig] to [Config]
4127pub struct ConfigBuilder {
4128    file: FileConfig,
4129    known_addresses: HashMap<SocketAddr, ListenerProtocol>,
4130    expect_proxy_addresses: HashSet<SocketAddr>,
4131    built: Config,
4132}
4133
4134impl ConfigBuilder {
4135    /// starts building a [Config] with values from a [FileConfig], or defaults.
4136    ///
4137    /// please provide a config path, usefull for rebuilding the config later.
4138    pub fn new<S>(file_config: FileConfig, config_path: S) -> Self
4139    where
4140        S: ToString,
4141    {
4142        let built = Config {
4143            accept_queue_timeout: file_config
4144                .accept_queue_timeout
4145                .unwrap_or(DEFAULT_ACCEPT_QUEUE_TIMEOUT),
4146            evict_on_queue_full: file_config
4147                .evict_on_queue_full
4148                .unwrap_or(DEFAULT_EVICT_ON_QUEUE_FULL),
4149            activate_listeners: file_config.activate_listeners.unwrap_or(true),
4150            automatic_state_save: file_config
4151                .automatic_state_save
4152                .unwrap_or(DEFAULT_AUTOMATIC_STATE_SAVE),
4153            back_timeout: file_config.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
4154            buffer_size: file_config.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE),
4155            command_buffer_size: file_config
4156                .command_buffer_size
4157                .unwrap_or(DEFAULT_COMMAND_BUFFER_SIZE),
4158            config_path: config_path.to_string(),
4159            connect_timeout: file_config
4160                .connect_timeout
4161                .unwrap_or(DEFAULT_CONNECT_TIMEOUT),
4162            ctl_command_timeout: file_config.ctl_command_timeout.unwrap_or(1_000),
4163            front_timeout: file_config.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
4164            handle_process_affinity: file_config.handle_process_affinity.unwrap_or(false),
4165            access_logs_target: file_config.access_logs_target.clone(),
4166            audit_logs_target: file_config.audit_logs_target.clone(),
4167            audit_logs_json_target: file_config.audit_logs_json_target.clone(),
4168            access_logs_format: file_config.access_logs_format.clone(),
4169            access_logs_colored: file_config.access_logs_colored,
4170            log_level: file_config
4171                .log_level
4172                .clone()
4173                .unwrap_or_else(|| String::from("info")),
4174            log_target: file_config
4175                .log_target
4176                .clone()
4177                .unwrap_or_else(|| String::from("stdout")),
4178            log_colored: file_config.log_colored,
4179            max_buffers: file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
4180            max_command_buffer_size: file_config
4181                .max_command_buffer_size
4182                .unwrap_or(DEFAULT_MAX_COMMAND_BUFFER_SIZE),
4183            max_connections: file_config
4184                .max_connections
4185                .unwrap_or(DEFAULT_MAX_CONNECTIONS),
4186            metrics: file_config.metrics.clone(),
4187            disable_cluster_metrics: file_config
4188                .disable_cluster_metrics
4189                .unwrap_or(DEFAULT_DISABLE_CLUSTER_METRICS),
4190            min_buffers: std::cmp::min(
4191                file_config.min_buffers.unwrap_or(DEFAULT_MIN_BUFFERS),
4192                file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
4193            ),
4194            pid_file_path: file_config.pid_file_path.clone(),
4195            request_timeout: file_config
4196                .request_timeout
4197                .unwrap_or(DEFAULT_REQUEST_TIMEOUT),
4198            saved_state: file_config.saved_state.clone(),
4199            worker_automatic_restart: file_config
4200                .worker_automatic_restart
4201                .unwrap_or(DEFAULT_WORKER_AUTOMATIC_RESTART),
4202            worker_count: file_config.worker_count.unwrap_or(DEFAULT_WORKER_COUNT),
4203            zombie_check_interval: file_config
4204                .zombie_check_interval
4205                .unwrap_or(DEFAULT_ZOMBIE_CHECK_INTERVAL),
4206            worker_timeout: file_config.worker_timeout.unwrap_or(DEFAULT_WORKER_TIMEOUT),
4207            slab_entries_per_connection: file_config.slab_entries_per_connection.map(|n| {
4208                n.clamp(
4209                    ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION,
4210                    ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION,
4211                )
4212            }),
4213            command_allowed_uids: file_config.command_allowed_uids.clone(),
4214            basic_auth_max_credential_bytes: file_config.basic_auth_max_credential_bytes,
4215            max_connections_per_ip: file_config
4216                .max_connections_per_ip
4217                .unwrap_or(DEFAULT_MAX_CONNECTIONS_PER_IP),
4218            max_connections_per_subnet: file_config
4219                .max_connections_per_subnet
4220                .unwrap_or(DEFAULT_MAX_CONNECTIONS_PER_SUBNET),
4221            subnet_ipv4_prefix: file_config
4222                .subnet_ipv4_prefix
4223                .unwrap_or(DEFAULT_SUBNET_IPV4_PREFIX),
4224            subnet_ipv6_prefix: file_config
4225                .subnet_ipv6_prefix
4226                .unwrap_or(DEFAULT_SUBNET_IPV6_PREFIX),
4227            retry_after: file_config.retry_after.unwrap_or(DEFAULT_RETRY_AFTER),
4228            max_connection_attempts: file_config
4229                .max_connection_attempts
4230                .unwrap_or(DEFAULT_MAX_CONNECTION_ATTEMPTS),
4231            splice_pipe_capacity_bytes: file_config.splice_pipe_capacity_bytes,
4232            ..Default::default()
4233        };
4234
4235        // POST: the buffer free-list floor is clamped to never exceed the
4236        // ceiling — the `std::cmp::min(min_buffers, max_buffers)` above is the
4237        // sole guarantor of this, so assert it held.
4238        debug_assert!(
4239            built.min_buffers <= built.max_buffers,
4240            "min_buffers must be clamped to <= max_buffers in the builder"
4241        );
4242        // POST: an explicit slab override, if present, was clamped into the
4243        // documented [MIN, MAX] window; an absent override stays None.
4244        debug_assert!(
4245            built.slab_entries_per_connection.is_none_or(|n| {
4246                (ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION
4247                    ..=ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION)
4248                    .contains(&n)
4249            }),
4250            "a set slab_entries_per_connection must be clamped into [MIN, MAX]"
4251        );
4252
4253        Self {
4254            file: file_config,
4255            known_addresses: HashMap::new(),
4256            expect_proxy_addresses: HashSet::new(),
4257            built,
4258        }
4259    }
4260
4261    fn push_tls_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
4262        let listener = listener.to_tls(Some(&self.built))?;
4263        self.built.https_listeners.push(listener);
4264        Ok(())
4265    }
4266
4267    fn push_http_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
4268        let listener = listener.to_http(Some(&self.built))?;
4269        self.built.http_listeners.push(listener);
4270        Ok(())
4271    }
4272
4273    fn push_tcp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
4274        let listener = listener.to_tcp(Some(&self.built))?;
4275        self.built.tcp_listeners.push(listener);
4276        Ok(())
4277    }
4278
4279    fn push_udp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
4280        let listener = listener.to_udp(Some(&self.built))?;
4281        self.built.udp_listeners.push(listener);
4282        Ok(())
4283    }
4284
4285    fn populate_listeners(&mut self, listeners: Vec<ListenerBuilder>) -> Result<(), ConfigError> {
4286        let mut declared: HashSet<ListenerKey> = HashSet::new();
4287        for listener in listeners.iter() {
4288            // A listener is identified by (address, interface): two listeners
4289            // may share an address on different interfaces, never both.
4290            let key = ListenerKey::new(listener.address, listener.interface.as_deref());
4291            if !declared.insert(key.clone()) {
4292                if key.interface.is_none() {
4293                    return Err(ConfigError::ListenerAddressAlreadyInUse(listener.address));
4294                }
4295                return Err(ConfigError::ListenerAlreadyDeclared(key));
4296            }
4297
4298            let protocol = listener
4299                .protocol
4300                .ok_or(ConfigError::Missing(MissingKind::Protocol))?;
4301
4302            // Frontends name their listener by address alone, and resolve its
4303            // protocol from `known_addresses`: listeners sharing an address on
4304            // different interfaces must agree on it.
4305            if let Some(known) = self.known_addresses.get(&listener.address)
4306                && *known != protocol
4307            {
4308                return Err(ConfigError::ListenerProtocolMismatchOnAddress(
4309                    listener.address,
4310                ));
4311            }
4312
4313            self.known_addresses.insert(listener.address, protocol);
4314            if listener.expect_proxy == Some(true) {
4315                self.expect_proxy_addresses.insert(listener.address);
4316            }
4317
4318            if listener.public_address.is_some() && listener.expect_proxy == Some(true) {
4319                return Err(ConfigError::Incompatible {
4320                    object: ObjectKind::Listener,
4321                    id: listener.address.to_string(),
4322                    kind: IncompatibilityKind::PublicAddress,
4323                });
4324            }
4325
4326            match protocol {
4327                ListenerProtocol::Https => self.push_tls_listener(listener.clone())?,
4328                ListenerProtocol::Http => self.push_http_listener(listener.clone())?,
4329                ListenerProtocol::Tcp => self.push_tcp_listener(listener.clone())?,
4330                ListenerProtocol::Udp => self.push_udp_listener(listener.clone())?,
4331            }
4332        }
4333        Ok(())
4334    }
4335
4336    fn populate_clusters(
4337        &mut self,
4338        mut file_cluster_configs: HashMap<String, FileClusterConfig>,
4339    ) -> Result<(), ConfigError> {
4340        for (id, file_cluster_config) in file_cluster_configs.drain() {
4341            let mut cluster_config =
4342                file_cluster_config.to_cluster_config(id.as_str(), &self.expect_proxy_addresses)?;
4343
4344            match cluster_config {
4345                ClusterConfig::Http(ref mut http) => {
4346                    for frontend in http.frontends.iter_mut() {
4347                        match self.known_addresses.get(&frontend.address) {
4348                            Some(ListenerProtocol::Tcp) => {
4349                                return Err(ConfigError::WrongFrontendProtocol(
4350                                    ListenerProtocol::Tcp,
4351                                ));
4352                            }
4353                            Some(ListenerProtocol::Udp) => {
4354                                return Err(ConfigError::WrongFrontendProtocol(
4355                                    ListenerProtocol::Udp,
4356                                ));
4357                            }
4358                            Some(ListenerProtocol::Http) => {
4359                                if frontend.certificate.is_some() {
4360                                    return Err(ConfigError::WrongFrontendProtocol(
4361                                        ListenerProtocol::Http,
4362                                    ));
4363                                }
4364                            }
4365                            Some(ListenerProtocol::Https) => {
4366                                if frontend.certificate.is_none() {
4367                                    if let Some(https_listener) =
4368                                        self.built.https_listeners.iter().find(|listener| {
4369                                            listener.address == frontend.address.into()
4370                                                && listener.certificate.is_some()
4371                                        })
4372                                    {
4373                                        //println!("using listener certificate for {:}", frontend.address);
4374                                        frontend
4375                                            .certificate
4376                                            .clone_from(&https_listener.certificate);
4377                                        frontend.certificate_chain =
4378                                            Some(https_listener.certificate_chain.clone());
4379                                        frontend.key.clone_from(&https_listener.key);
4380                                    }
4381                                    if frontend.certificate.is_none() {
4382                                        debug!("known addresses: {:?}", self.known_addresses);
4383                                        debug!("frontend: {:?}", frontend);
4384                                        return Err(ConfigError::WrongFrontendProtocol(
4385                                            ListenerProtocol::Https,
4386                                        ));
4387                                    }
4388                                }
4389                            }
4390                            None => {
4391                                // create a default listener for that front
4392                                let file_listener_protocol = if frontend.certificate.is_some() {
4393                                    self.push_tls_listener(ListenerBuilder::new(
4394                                        frontend.address.into(),
4395                                        ListenerProtocol::Https,
4396                                    ))?;
4397
4398                                    ListenerProtocol::Https
4399                                } else {
4400                                    self.push_http_listener(ListenerBuilder::new(
4401                                        frontend.address.into(),
4402                                        ListenerProtocol::Http,
4403                                    ))?;
4404
4405                                    ListenerProtocol::Http
4406                                };
4407                                self.known_addresses
4408                                    .insert(frontend.address, file_listener_protocol);
4409                            }
4410                        }
4411                    }
4412                }
4413                ClusterConfig::Tcp(ref mut tcp) => {
4414                    //FIXME: verify that different TCP clusters do not request the same address
4415                    for frontend in tcp.frontends.iter_mut() {
4416                        match self.known_addresses.get(&frontend.address) {
4417                            Some(ListenerProtocol::Http) | Some(ListenerProtocol::Https) => {
4418                                return Err(ConfigError::WrongFrontendProtocol(
4419                                    ListenerProtocol::Http,
4420                                ));
4421                            }
4422                            Some(ListenerProtocol::Udp) => {
4423                                // A `protocol = "tcp"` cluster whose frontend
4424                                // points at a `protocol = "udp"` listener is a
4425                                // UDP cluster (datagram knobs under
4426                                // `[clusters.<id>.udp]`). Mark the frontend so
4427                                // `generate_requests` emits `AddUdpFrontend`.
4428                                frontend.udp = true;
4429                            }
4430                            Some(ListenerProtocol::Tcp) => {}
4431                            None => {
4432                                // create a default listener for that front
4433                                self.push_tcp_listener(ListenerBuilder::new(
4434                                    frontend.address.into(),
4435                                    ListenerProtocol::Tcp,
4436                                ))?;
4437                                self.known_addresses
4438                                    .insert(frontend.address, ListenerProtocol::Tcp);
4439                            }
4440                        }
4441                    }
4442                }
4443            }
4444
4445            self.built.clusters.insert(id, cluster_config);
4446        }
4447        Ok(())
4448    }
4449
4450    /// Builds a [`Config`], populated with listeners and clusters
4451    pub fn into_config(&mut self) -> Result<Config, ConfigError> {
4452        // Subnet prefix lengths must fit their address family. Checked
4453        // here rather than clamped in `ConfigBuilder::new` (which cannot
4454        // fail) so a typo such as `subnet_ipv4_prefix = 64` stops the
4455        // load instead of quietly enforcing /32.
4456        if self.built.subnet_ipv4_prefix > MAX_SUBNET_IPV4_PREFIX {
4457            return Err(ConfigError::InvalidSubnetPrefix {
4458                key: "subnet_ipv4_prefix",
4459                family: "IPv4",
4460                value: self.built.subnet_ipv4_prefix,
4461                maximum: MAX_SUBNET_IPV4_PREFIX,
4462            });
4463        }
4464        if self.built.subnet_ipv6_prefix > MAX_SUBNET_IPV6_PREFIX {
4465            return Err(ConfigError::InvalidSubnetPrefix {
4466                key: "subnet_ipv6_prefix",
4467                family: "IPv6",
4468                value: self.built.subnet_ipv6_prefix,
4469                maximum: MAX_SUBNET_IPV6_PREFIX,
4470            });
4471        }
4472        validate_max_connection_attempts(self.built.max_connection_attempts).map_err(|reason| {
4473            ConfigError::InvalidMaxConnectionAttempts {
4474                cluster_id: None,
4475                value: self.built.max_connection_attempts,
4476                reason,
4477            }
4478        })?;
4479
4480        if let Some(listeners) = &self.file.listeners {
4481            self.populate_listeners(listeners.clone())?;
4482        }
4483
4484        if let Some(file_cluster_configs) = &self.file.clusters {
4485            self.populate_clusters(file_cluster_configs.clone())?;
4486        }
4487
4488        // TCP SNI/ALPN routing invariants (sozu-proxy/sozu#1279). Collect
4489        // every TCP frontend's (address, sni, alpn) across all clusters,
4490        // then validate:
4491        //   (c) a listener must not mix a no-SNI frontend with any
4492        //       SNI-scoped frontend — an SNI-enabled listener prereads the
4493        //       ClientHello, so a raw-TCP fallback on the same address is
4494        //       unreachable for clients that don't send SNI and ambiguous
4495        //       for those that do;
4496        //   (b) two frontends on the same (address, sni) must not share an
4497        //       ALPN protocol, and at most one may leave alpn empty (the
4498        //       catch-all) — otherwise routing on that listener would
4499        //       depend on iteration order rather than configuration;
4500        //   (d)/(e) sni_preread_timeout must not exceed front_timeout, and
4501        //       sni_preread_max_bytes must not exceed buffer_size, on any
4502        //       listener an SNI frontend targets. Both are gated on
4503        //       SNI-presence (not just any TCP listener) so a legacy
4504        //       TCP-only config with a low front_timeout or small
4505        //       buffer_size — set long before this feature existed and
4506        //       never opting into SNI — keeps loading byte-identically.
4507        type SniAlpnByAddress = HashMap<SocketAddr, Vec<(Option<String>, Vec<String>)>>;
4508        let mut frontends_by_address: SniAlpnByAddress = HashMap::new();
4509        let mut addresses_with_no_sni_frontend: HashSet<SocketAddr> = HashSet::new();
4510        for cluster in self.built.clusters.values() {
4511            if let ClusterConfig::Tcp(tcp) = cluster {
4512                for frontend in &tcp.frontends {
4513                    // A `protocol = "tcp"` cluster frontend resolved against a
4514                    // `protocol = "udp"` listener (`frontend.udp`, set in
4515                    // `populate_clusters`) emits `AddUdpFrontend`, which
4516                    // carries no `sni`/`alpn` on the wire — it is not a real
4517                    // SNI-preread TCP frontend and must not participate in
4518                    // these TCP-only invariants.
4519                    if frontend.udp {
4520                        continue;
4521                    }
4522                    if frontend.sni.is_none() {
4523                        addresses_with_no_sni_frontend.insert(frontend.address);
4524                    }
4525                    frontends_by_address
4526                        .entry(frontend.address)
4527                        .or_default()
4528                        .push((frontend.sni.clone(), frontend.alpn.clone()));
4529                }
4530            }
4531        }
4532
4533        let mut addresses_with_sni_frontend: HashSet<SocketAddr> = HashSet::new();
4534        for (address, frontends) in &frontends_by_address {
4535            if !frontends.iter().any(|(sni, _)| sni.is_some()) {
4536                continue;
4537            }
4538            addresses_with_sni_frontend.insert(*address);
4539
4540            if addresses_with_no_sni_frontend.contains(address) {
4541                return Err(ConfigError::TcpListenerMixesSniAndNoSni { address: *address });
4542            }
4543
4544            let mut alpn_lists_by_sni: HashMap<Option<String>, Vec<&Vec<String>>> = HashMap::new();
4545            for (sni, alpn) in frontends {
4546                alpn_lists_by_sni.entry(sni.clone()).or_default().push(alpn);
4547            }
4548            for (sni, alpn_lists) in alpn_lists_by_sni {
4549                let mut seen_protocols: HashSet<&str> = HashSet::new();
4550                let mut catch_all_count = 0usize;
4551                for alpn in alpn_lists {
4552                    if alpn.is_empty() {
4553                        catch_all_count += 1;
4554                        if catch_all_count > 1 {
4555                            return Err(ConfigError::TcpFrontendMultipleAlpnCatchAll {
4556                                address: *address,
4557                                sni: sni.clone(),
4558                            });
4559                        }
4560                        continue;
4561                    }
4562                    for protocol in alpn {
4563                        if !seen_protocols.insert(protocol.as_str()) {
4564                            return Err(ConfigError::TcpFrontendAlpnOverlap {
4565                                address: *address,
4566                                sni: sni.clone(),
4567                                protocol: protocol.clone(),
4568                            });
4569                        }
4570                    }
4571                }
4572            }
4573        }
4574
4575        for listener in &self.built.tcp_listeners {
4576            let address: SocketAddr = listener.address.into();
4577            if !addresses_with_sni_frontend.contains(&address) {
4578                continue;
4579            }
4580            let sni_preread_timeout = listener
4581                .sni_preread_timeout
4582                .unwrap_or(DEFAULT_SNI_PREREAD_TIMEOUT);
4583            if sni_preread_timeout > listener.front_timeout {
4584                return Err(ConfigError::SniPrereadTimeoutExceedsFrontTimeout {
4585                    address,
4586                    sni_preread_timeout,
4587                    front_timeout: listener.front_timeout,
4588                });
4589            }
4590            let sni_preread_max_bytes = listener
4591                .sni_preread_max_bytes
4592                .unwrap_or(DEFAULT_SNI_PREREAD_MAX_BYTES);
4593            if sni_preread_max_bytes < MIN_SNI_PREREAD_MAX_BYTES {
4594                return Err(ConfigError::SniPrereadMaxBytesTooSmall {
4595                    address,
4596                    sni_preread_max_bytes,
4597                    minimum: MIN_SNI_PREREAD_MAX_BYTES,
4598                });
4599            }
4600            if u64::from(sni_preread_max_bytes) > self.built.buffer_size {
4601                return Err(ConfigError::SniPrereadMaxBytesExceedsBufferSize {
4602                    address,
4603                    sni_preread_max_bytes,
4604                    buffer_size: self.built.buffer_size,
4605                });
4606            }
4607        }
4608
4609        // RFC 9113 §6.5.2 + §4.1: the H2 mux must accept up to
4610        // SETTINGS_MAX_FRAME_SIZE (16 384) + 9-byte frame header in a single
4611        // kawa buffer. If any HTTPS listener advertises "h2" in its ALPN list
4612        // and the global buffer_size is below H2_MIN_BUFFER_SIZE, the mux
4613        // deadlocks on full-size DATA / HEADERS / CONTINUATION frames until
4614        // the session timeout fires. Reject at config load so the failure
4615        // mode surfaces at boot, not under traffic.
4616        // Long-form rationale: `lib/src/protocol/mux/LIFECYCLE.md`.
4617        let h2_listeners = self
4618            .built
4619            .https_listeners
4620            .iter()
4621            .filter(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
4622            .count();
4623        if h2_listeners > 0 && self.built.buffer_size < H2_MIN_BUFFER_SIZE {
4624            return Err(ConfigError::BufferSizeTooSmallForH2 {
4625                buffer_size: self.built.buffer_size,
4626                minimum: H2_MIN_BUFFER_SIZE,
4627                listeners: h2_listeners,
4628            });
4629        }
4630
4631        // The command channel (`Channel::new`, `command/src/channel.rs`) allocates its
4632        // initial buffer at `command_buffer_size` and must never let it exceed
4633        // `max_command_buffer_size` -- every growth/shrink path on that channel reasons
4634        // against that ceiling. Reject at config load, where the error can name both
4635        // keys and both values, rather than let a misconfigured pair reach the real
4636        // master<->worker channel (sozu-proxy/sozu#1416).
4637        if self.built.command_buffer_size > self.built.max_command_buffer_size {
4638            return Err(ConfigError::CommandBufferSizeExceedsMax {
4639                command_buffer_size: self.built.command_buffer_size,
4640                max_command_buffer_size: self.built.max_command_buffer_size,
4641            });
4642        }
4643
4644        // Warn (no hard reject) when the configured Basic-auth credential
4645        // cap is large enough to dominate the per-frontend buffer. The
4646        // worker copies a decoded credential into a transient allocation
4647        // sized by this cap; values >= 33% of `buffer_size` mean a single
4648        // failed-auth attempt can hold a third of the buffer's worth of
4649        // bytes, which combined with in-flight request/response framing
4650        // pushes the buffer toward back-pressure under load. Log only —
4651        // operators with deliberate threat models may choose this
4652        // trade-off, but the surprise needs to be visible.
4653        if let Some(cap) = self.built.basic_auth_max_credential_bytes {
4654            let third = self.built.buffer_size / 3;
4655            if cap >= third {
4656                warn!(
4657                    "basic_auth_max_credential_bytes = {} is >= buffer_size / 3 ({}); \
4658                     a hostile peer can pin ~33% of the per-frontend buffer per failed auth \
4659                     attempt. Consider lowering basic_auth_max_credential_bytes (typical \
4660                     credentials are <100 bytes) or raising buffer_size.",
4661                    cap, third
4662                );
4663            }
4664        }
4665
4666        // The eviction batch is `(max_connections / 100).max(1)` — a 1% ratio
4667        // by design. Below 100 connections the floor of 1 means each cap
4668        // event evicts a larger share than 1% of capacity (e.g. 4% at
4669        // max_connections=25), which can surprise an operator who reads the
4670        // knob as "1% per round". Warn at config load so the discrepancy is
4671        // visible at boot, not under traffic.
4672        if self.built.evict_on_queue_full && self.built.max_connections < 100 {
4673            let pct = 100usize.div_ceil(self.built.max_connections);
4674            warn!(
4675                "evict_on_queue_full enabled with max_connections = {}; the eviction batch \
4676                 clamps to 1, equivalent to ~{}% of capacity per cap event (the knob is \
4677                 documented as 1%). Confirm this is intended.",
4678                self.built.max_connections, pct
4679            );
4680        }
4681
4682        let command_socket_path = self.file.command_socket.clone().unwrap_or({
4683            let mut path = env::current_dir().map_err(|e| ConfigError::Env(e.to_string()))?;
4684            path.push("sozu.sock");
4685            let verified_path = path
4686                .to_str()
4687                .ok_or(ConfigError::InvalidPath(path.clone()))?;
4688            verified_path.to_owned()
4689        });
4690
4691        if let (None, Some(true)) = (&self.file.saved_state, &self.file.automatic_state_save) {
4692            return Err(ConfigError::Missing(MissingKind::SavedState));
4693        }
4694
4695        let command_socket_mode = match self.file.command_socket_mode.as_deref() {
4696            Some(mode) => parse_command_socket_mode(mode)?,
4697            None => DEFAULT_COMMAND_SOCKET_MODE,
4698        };
4699
4700        let config = Config {
4701            command_socket: command_socket_path,
4702            command_socket_mode,
4703            ..self.built.clone()
4704        };
4705
4706        // POST: a successfully built config satisfies the buffer-pool
4707        // invariants every worker relies on.
4708        // 1. min_buffers <= max_buffers — guaranteed by the `std::cmp::min`
4709        //    clamp in `new`; a violation would let a worker size its free-list
4710        //    floor above its ceiling.
4711        debug_assert!(
4712            config.min_buffers <= config.max_buffers,
4713            "min_buffers must not exceed max_buffers"
4714        );
4715        // 2. If any HTTPS listener advertises h2 in its ALPN, the global
4716        //    buffer_size is at least the H2 minimum — otherwise the early
4717        //    return above would have produced a BufferSizeTooSmallForH2 error
4718        //    rather than this Ok. (Recomputed here so the assert is independent
4719        //    of the local `h2_listeners` binding above.)
4720        debug_assert!(
4721            !config
4722                .https_listeners
4723                .iter()
4724                .any(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
4725                || config.buffer_size >= H2_MIN_BUFFER_SIZE,
4726            "an h2-advertising config must satisfy the H2 minimum buffer size"
4727        );
4728        Ok(config)
4729    }
4730}
4731
4732/// Sōzu configuration, populated with clusters and listeners.
4733///
4734/// This struct is used on startup to generate `WorkerRequest`s
4735#[derive(Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
4736pub struct Config {
4737    pub config_path: String,
4738    pub command_socket: String,
4739    /// Permission bits applied to the command socket after `bind(2)`, at most
4740    /// [`MAX_COMMAND_SOCKET_MODE`]. An upgrade snapshot written by a main
4741    /// process that predates the key deserializes to
4742    /// [`DEFAULT_COMMAND_SOCKET_MODE`].
4743    #[serde(default = "default_command_socket_mode")]
4744    pub command_socket_mode: u32,
4745    pub command_buffer_size: u64,
4746    pub max_command_buffer_size: u64,
4747    pub max_connections: usize,
4748    pub min_buffers: u64,
4749    pub max_buffers: u64,
4750    pub buffer_size: u64,
4751    pub saved_state: Option<String>,
4752    #[serde(default)]
4753    pub automatic_state_save: bool,
4754    pub log_level: String,
4755    pub log_target: String,
4756    pub log_colored: bool,
4757    /// Optional dedicated file path for the control-plane audit log. See
4758    /// `FileConfig::audit_logs_target` for rationale.
4759    #[serde(default)]
4760    pub audit_logs_target: Option<String>,
4761    /// Optional JSON mirror of the audit log; see
4762    /// `FileConfig::audit_logs_json_target`.
4763    #[serde(default)]
4764    pub audit_logs_json_target: Option<String>,
4765    #[serde(default)]
4766    pub access_logs_target: Option<String>,
4767    pub access_logs_format: Option<AccessLogFormat>,
4768    pub access_logs_colored: Option<bool>,
4769    pub worker_count: u16,
4770    pub worker_automatic_restart: bool,
4771    pub metrics: Option<MetricsConfig>,
4772    #[serde(default = "default_disable_cluster_metrics")]
4773    pub disable_cluster_metrics: bool,
4774    pub http_listeners: Vec<HttpListenerConfig>,
4775    pub https_listeners: Vec<HttpsListenerConfig>,
4776    pub tcp_listeners: Vec<TcpListenerConfig>,
4777    #[serde(default)]
4778    pub udp_listeners: Vec<UdpListenerConfig>,
4779    pub clusters: HashMap<String, ClusterConfig>,
4780    pub handle_process_affinity: bool,
4781    pub ctl_command_timeout: u64,
4782    pub pid_file_path: Option<String>,
4783    pub activate_listeners: bool,
4784    #[serde(default = "default_front_timeout")]
4785    pub front_timeout: u32,
4786    #[serde(default = "default_back_timeout")]
4787    pub back_timeout: u32,
4788    #[serde(default = "default_connect_timeout")]
4789    pub connect_timeout: u32,
4790    #[serde(default = "default_zombie_check_interval")]
4791    pub zombie_check_interval: u32,
4792    #[serde(default = "default_accept_queue_timeout")]
4793    pub accept_queue_timeout: u32,
4794    #[serde(default = "default_evict_on_queue_full")]
4795    pub evict_on_queue_full: bool,
4796    #[serde(default = "default_request_timeout")]
4797    pub request_timeout: u32,
4798    #[serde(default = "default_worker_timeout")]
4799    pub worker_timeout: u32,
4800    /// Slab-entries-per-connection multiplier exposed for operators with
4801    /// fan-out topologies that exceed the default 4 backends per session.
4802    /// `None` means the default (4) applies; set values are clamped to
4803    /// [`ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION`,
4804    /// `ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION`] = [2, 32]. Slab
4805    /// capacity is `10 + slab_entries_per_connection * max_connections`.
4806    #[serde(default)]
4807    pub slab_entries_per_connection: Option<u64>,
4808    /// Optional allowlist of UIDs permitted to invoke command-socket
4809    /// requests. `None` keeps the historical "any same-UID local process"
4810    /// behaviour. When `Some`, every request whose `SO_PEERCRED` UID is
4811    /// not in the list is rejected before reaching dispatch.
4812    #[serde(default)]
4813    pub command_allowed_uids: Option<Vec<u32>>,
4814    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
4815    /// payload accepted by `mux::auth`. `None` keeps the compile-time
4816    /// default of 4096. Set once on each worker at boot via
4817    /// [`ServerConfig::basic_auth_max_credential_bytes`].
4818    #[serde(default)]
4819    pub basic_auth_max_credential_bytes: Option<u64>,
4820    /// Default per-(cluster, source-IP) connection limit. `0` means
4821    /// unlimited. Each cluster may override via its own
4822    /// `max_connections_per_ip`. Source IP attribution honours the
4823    /// proxy-protocol header when present. UDP listeners ignore it: only a
4824    /// cluster's own value limits UDP flows.
4825    #[serde(default = "default_max_connections_per_ip")]
4826    pub max_connections_per_ip: u64,
4827    /// Default per-(cluster, source-SUBNET) connection limit. `0` means
4828    /// unlimited and is the default, so the subnet limiter is inert
4829    /// until an operator turns it on. An independent second counter
4830    /// beside `max_connections_per_ip`, not a replacement: both gates
4831    /// must admit. Each cluster may override.
4832    #[serde(default = "default_max_connections_per_subnet")]
4833    pub max_connections_per_subnet: u64,
4834    /// IPv4 prefix length (bits, 0-32) defining the subnet key. 32
4835    /// (default) masks nothing.
4836    #[serde(default = "default_subnet_ipv4_prefix")]
4837    pub subnet_ipv4_prefix: u32,
4838    /// IPv6 prefix length (bits, 0-128) defining the subnet key. 128
4839    /// (default) masks nothing.
4840    #[serde(default = "default_subnet_ipv6_prefix")]
4841    pub subnet_ipv6_prefix: u32,
4842    /// Default `Retry-After` header value (seconds) emitted on HTTP 429
4843    /// responses. `0` omits the header.
4844    #[serde(default = "default_retry_after")]
4845    pub retry_after: u32,
4846    /// Backend connection attempt budget of every request, the first attempt
4847    /// included. Each cluster may override it.
4848    #[serde(default = "default_max_connection_attempts")]
4849    pub max_connection_attempts: u32,
4850    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
4851    /// zero-copy direction. `None` keeps the kernel default of 64 KiB.
4852    /// Applied via `fcntl(F_SETPIPE_SZ)` per pipe at `SplicePipe::new`;
4853    /// the kernel rounds up to a page boundary and clamps at
4854    /// `/proc/sys/fs/pipe-max-size`. Linux-only; ignored on builds
4855    /// without the `splice` feature.
4856    #[serde(default)]
4857    pub splice_pipe_capacity_bytes: Option<u64>,
4858}
4859
4860fn default_command_socket_mode() -> u32 {
4861    DEFAULT_COMMAND_SOCKET_MODE
4862}
4863
4864/// Parse `command_socket_mode`: one to four octal digits, a leading `0`
4865/// allowed (`"0660"` and `"660"` are the same mode), no `0o` prefix and no
4866/// sign. The value must not exceed [`MAX_COMMAND_SOCKET_MODE`], which refuses
4867/// setuid, setgid and the sticky bit, and must keep the owner's read and
4868/// write bits: without them the proxy's own user could no longer connect to
4869/// run `status`, `reload` or `upgrade`.
4870pub fn parse_command_socket_mode(value: &str) -> Result<u32, ConfigError> {
4871    let invalid = |reason| ConfigError::InvalidCommandSocketMode {
4872        value: value.to_owned(),
4873        reason,
4874    };
4875    if value.is_empty() || value.len() > 4 || !value.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
4876        return Err(invalid(
4877            "expected one to four octal digits, such as \"0660\" or \"660\"",
4878        ));
4879    }
4880    let mode =
4881        u32::from_str_radix(value, 8).map_err(|_| invalid("expected one to four octal digits"))?;
4882    if mode > MAX_COMMAND_SOCKET_MODE {
4883        return Err(invalid(
4884            "only permission bits (at most 0777) are allowed; setuid, setgid and sticky are refused",
4885        ));
4886    }
4887    if mode & 0o600 != 0o600 {
4888        return Err(invalid("the owner must keep read and write (0600)"));
4889    }
4890    Ok(mode)
4891}
4892
4893fn default_front_timeout() -> u32 {
4894    DEFAULT_FRONT_TIMEOUT
4895}
4896
4897fn default_back_timeout() -> u32 {
4898    DEFAULT_BACK_TIMEOUT
4899}
4900
4901fn default_connect_timeout() -> u32 {
4902    DEFAULT_CONNECT_TIMEOUT
4903}
4904
4905fn default_request_timeout() -> u32 {
4906    DEFAULT_REQUEST_TIMEOUT
4907}
4908
4909fn default_zombie_check_interval() -> u32 {
4910    DEFAULT_ZOMBIE_CHECK_INTERVAL
4911}
4912
4913fn default_accept_queue_timeout() -> u32 {
4914    DEFAULT_ACCEPT_QUEUE_TIMEOUT
4915}
4916
4917fn default_evict_on_queue_full() -> bool {
4918    DEFAULT_EVICT_ON_QUEUE_FULL
4919}
4920
4921fn default_disable_cluster_metrics() -> bool {
4922    DEFAULT_DISABLE_CLUSTER_METRICS
4923}
4924
4925fn default_worker_timeout() -> u32 {
4926    DEFAULT_WORKER_TIMEOUT
4927}
4928
4929fn default_max_connections_per_ip() -> u64 {
4930    DEFAULT_MAX_CONNECTIONS_PER_IP
4931}
4932
4933fn default_max_connections_per_subnet() -> u64 {
4934    DEFAULT_MAX_CONNECTIONS_PER_SUBNET
4935}
4936
4937fn default_subnet_ipv4_prefix() -> u32 {
4938    DEFAULT_SUBNET_IPV4_PREFIX
4939}
4940
4941fn default_subnet_ipv6_prefix() -> u32 {
4942    DEFAULT_SUBNET_IPV6_PREFIX
4943}
4944
4945fn default_retry_after() -> u32 {
4946    DEFAULT_RETRY_AFTER
4947}
4948
4949fn default_max_connection_attempts() -> u32 {
4950    DEFAULT_MAX_CONNECTION_ATTEMPTS
4951}
4952
4953impl Config {
4954    /// Parse a TOML file and build a config out of it
4955    pub fn load_from_path(path: &str) -> Result<Config, ConfigError> {
4956        let file_config = FileConfig::load_from_path(path)?;
4957
4958        let mut config = ConfigBuilder::new(file_config, path).into_config()?;
4959
4960        // replace saved_state with a verified path
4961        config.saved_state = config.saved_state_path()?;
4962
4963        Ok(config)
4964    }
4965
4966    /// yields requests intended to recreate a proxy that match the config
4967    pub fn generate_config_messages(&self) -> Result<Vec<WorkerRequest>, ConfigError> {
4968        let mut v = Vec::new();
4969        let mut count = 0u8;
4970
4971        for listener in &self.http_listeners {
4972            v.push(WorkerRequest {
4973                id: format!("CONFIG-{count}"),
4974                content: RequestType::AddHttpListener(listener.clone()).into(),
4975            });
4976            count += 1;
4977        }
4978
4979        for listener in &self.https_listeners {
4980            v.push(WorkerRequest {
4981                id: format!("CONFIG-{count}"),
4982                content: RequestType::add_https_listener(listener.clone()).into(),
4983            });
4984            count += 1;
4985        }
4986
4987        for listener in &self.tcp_listeners {
4988            v.push(WorkerRequest {
4989                id: format!("CONFIG-{count}"),
4990                content: RequestType::AddTcpListener(listener.clone()).into(),
4991            });
4992            count += 1;
4993        }
4994
4995        for listener in &self.udp_listeners {
4996            v.push(WorkerRequest {
4997                id: format!("CONFIG-{count}"),
4998                content: RequestType::AddUdpListener(listener.clone()).into(),
4999            });
5000            count += 1;
5001        }
5002
5003        for cluster in self.clusters.values() {
5004            let mut orders = cluster.generate_requests()?;
5005            for content in orders.drain(..) {
5006                v.push(WorkerRequest {
5007                    id: format!("CONFIG-{count}"),
5008                    content,
5009                });
5010                count += 1;
5011            }
5012        }
5013
5014        if self.activate_listeners {
5015            for listener in &self.http_listeners {
5016                v.push(WorkerRequest {
5017                    id: format!("CONFIG-{count}"),
5018                    content: RequestType::ActivateListener(ActivateListener {
5019                        interface: listener.interface.clone(),
5020                        address: listener.address,
5021                        proxy: ListenerType::Http.into(),
5022                        from_scm: false,
5023                    })
5024                    .into(),
5025                });
5026                count += 1;
5027            }
5028
5029            for listener in &self.https_listeners {
5030                v.push(WorkerRequest {
5031                    id: format!("CONFIG-{count}"),
5032                    content: RequestType::ActivateListener(ActivateListener {
5033                        interface: listener.interface.clone(),
5034                        address: listener.address,
5035                        proxy: ListenerType::Https.into(),
5036                        from_scm: false,
5037                    })
5038                    .into(),
5039                });
5040                count += 1;
5041            }
5042
5043            for listener in &self.tcp_listeners {
5044                v.push(WorkerRequest {
5045                    id: format!("CONFIG-{count}"),
5046                    content: RequestType::ActivateListener(ActivateListener {
5047                        interface: listener.interface.clone(),
5048                        address: listener.address,
5049                        proxy: ListenerType::Tcp.into(),
5050                        from_scm: false,
5051                    })
5052                    .into(),
5053                });
5054                count += 1;
5055            }
5056
5057            for listener in &self.udp_listeners {
5058                v.push(WorkerRequest {
5059                    id: format!("CONFIG-{count}"),
5060                    content: RequestType::ActivateListener(ActivateListener {
5061                        interface: listener.interface.clone(),
5062                        address: listener.address,
5063                        proxy: ListenerType::Udp.into(),
5064                        from_scm: false,
5065                    })
5066                    .into(),
5067                });
5068                count += 1;
5069            }
5070        }
5071
5072        if self.disable_cluster_metrics {
5073            v.push(WorkerRequest {
5074                id: format!("CONFIG-{count}"),
5075                content: RequestType::ConfigureMetrics(MetricsConfiguration::Disabled.into())
5076                    .into(),
5077            });
5078            // count += 1; // uncomment if code is added below
5079        }
5080
5081        Ok(v)
5082    }
5083
5084    /// Get the path of the UNIX socket used to communicate with Sōzu
5085    pub fn command_socket_path(&self) -> Result<String, ConfigError> {
5086        let config_path_buf = PathBuf::from(self.config_path.clone());
5087        let mut config_dir = config_path_buf
5088            .parent()
5089            .ok_or(ConfigError::NoFileParent(
5090                config_path_buf.to_string_lossy().to_string(),
5091            ))?
5092            .to_path_buf();
5093
5094        let socket_path = PathBuf::from(self.command_socket.clone());
5095
5096        let mut socket_parent_dir = match socket_path.parent() {
5097            // if the socket path is of the form "./sozu.sock",
5098            // then the parent is the directory where config.toml is situated
5099            None => config_dir,
5100            Some(path) => {
5101                // concatenate the config directory and the relative path of the socket
5102                config_dir.push(path);
5103                // canonicalize to remove double dots like /path/to/config/directory/../../path/to/socket/directory/
5104                config_dir.canonicalize().map_err(|io_error| {
5105                    ConfigError::SocketPathError(format!(
5106                        "Could not canonicalize path {config_dir:?}: {io_error}"
5107                    ))
5108                })?
5109            }
5110        };
5111
5112        let socket_name = socket_path
5113            .file_name()
5114            .ok_or(ConfigError::SocketPathError(format!(
5115                "could not get command socket file name from {socket_path:?}"
5116            )))?;
5117
5118        // concatenate parent directory and socket file name
5119        socket_parent_dir.push(socket_name);
5120
5121        let command_socket_path = socket_parent_dir
5122            .to_str()
5123            .ok_or(ConfigError::SocketPathError(format!(
5124                "Invalid socket path {socket_parent_dir:?}"
5125            )))?
5126            .to_string();
5127
5128        Ok(command_socket_path)
5129    }
5130
5131    /// Get the path of where the state will be saved
5132    fn saved_state_path(&self) -> Result<Option<String>, ConfigError> {
5133        let path = match self.saved_state.as_ref() {
5134            Some(path) => path,
5135            None => return Ok(None),
5136        };
5137
5138        debug!("saved_stated path in the config: {}", path);
5139        let config_path = PathBuf::from(self.config_path.clone());
5140
5141        debug!("Config path buffer: {:?}", config_path);
5142        let config_dir = config_path
5143            .parent()
5144            .ok_or(ConfigError::SaveStatePath(format!(
5145                "Could get parent directory of config file {config_path:?}"
5146            )))?;
5147
5148        debug!("Config folder: {:?}", config_dir);
5149        if !config_dir.exists() {
5150            create_dir_all(config_dir).map_err(|io_error| {
5151                ConfigError::SaveStatePath(format!(
5152                    "failed to create state parent directory '{config_dir:?}': {io_error}"
5153                ))
5154            })?;
5155        }
5156
5157        let mut saved_state_path_raw = config_dir.to_path_buf();
5158        saved_state_path_raw.push(path);
5159        debug!(
5160            "Looking for saved state on the path {:?}",
5161            saved_state_path_raw
5162        );
5163
5164        match metadata(path) {
5165            Err(err) if matches!(err.kind(), ErrorKind::NotFound) => {
5166                info!("Create an empty state file at '{}'", path);
5167                File::create(path).map_err(|io_error| {
5168                    ConfigError::SaveStatePath(format!(
5169                        "failed to create state file '{path:?}': {io_error}"
5170                    ))
5171                })?;
5172            }
5173            _ => {}
5174        }
5175
5176        saved_state_path_raw.canonicalize().map_err(|io_error| {
5177            ConfigError::SaveStatePath(format!(
5178                "could not get saved state path from config file input {path:?}: {io_error}"
5179            ))
5180        })?;
5181
5182        let stringified_path = saved_state_path_raw
5183            .to_str()
5184            .ok_or(ConfigError::SaveStatePath(format!(
5185                "Invalid path {saved_state_path_raw:?}"
5186            )))?
5187            .to_string();
5188
5189        Ok(Some(stringified_path))
5190    }
5191
5192    /// read any file to a string
5193    pub fn load_file(path: &str) -> Result<String, ConfigError> {
5194        std::fs::read_to_string(path).map_err(|io_error| ConfigError::FileRead {
5195            path_to_read: path.to_owned(),
5196            io_error,
5197        })
5198    }
5199
5200    /// read any file to bytes
5201    pub fn load_file_bytes(path: &str) -> Result<Vec<u8>, ConfigError> {
5202        std::fs::read(path).map_err(|io_error| ConfigError::FileRead {
5203            path_to_read: path.to_owned(),
5204            io_error,
5205        })
5206    }
5207}
5208
5209impl fmt::Debug for Config {
5210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5211        f.debug_struct("Config")
5212            .field("config_path", &self.config_path)
5213            .field("command_socket", &self.command_socket)
5214            .field(
5215                "command_socket_mode",
5216                &format_args!("{:o}", self.command_socket_mode),
5217            )
5218            .field("command_buffer_size", &self.command_buffer_size)
5219            .field("max_command_buffer_size", &self.max_command_buffer_size)
5220            .field("max_connections", &self.max_connections)
5221            .field("min_buffers", &self.min_buffers)
5222            .field("max_buffers", &self.max_buffers)
5223            .field("buffer_size", &self.buffer_size)
5224            .field("saved_state", &self.saved_state)
5225            .field("automatic_state_save", &self.automatic_state_save)
5226            .field("log_level", &self.log_level)
5227            .field("log_target", &self.log_target)
5228            .field("access_logs_target", &self.access_logs_target)
5229            .field("audit_logs_target", &self.audit_logs_target)
5230            .field("audit_logs_json_target", &self.audit_logs_json_target)
5231            .field("access_logs_format", &self.access_logs_format)
5232            .field("worker_count", &self.worker_count)
5233            .field("worker_automatic_restart", &self.worker_automatic_restart)
5234            .field("metrics", &self.metrics)
5235            .field("disable_cluster_metrics", &self.disable_cluster_metrics)
5236            .field("handle_process_affinity", &self.handle_process_affinity)
5237            .field("ctl_command_timeout", &self.ctl_command_timeout)
5238            .field("pid_file_path", &self.pid_file_path)
5239            .field("activate_listeners", &self.activate_listeners)
5240            .field("front_timeout", &self.front_timeout)
5241            .field("back_timeout", &self.back_timeout)
5242            .field("connect_timeout", &self.connect_timeout)
5243            .field("zombie_check_interval", &self.zombie_check_interval)
5244            .field("accept_queue_timeout", &self.accept_queue_timeout)
5245            .field("evict_on_queue_full", &self.evict_on_queue_full)
5246            .field("request_timeout", &self.request_timeout)
5247            .field("worker_timeout", &self.worker_timeout)
5248            .finish()
5249    }
5250}
5251
5252fn display_toml_error(file: &str, error: &toml::de::Error) {
5253    println!("error parsing the configuration file '{file}': {error}");
5254    if let Some(Range { start, end }) = error.span() {
5255        print!("error parsing the configuration file '{file}' at position: {start}, {end}");
5256    }
5257}
5258
5259impl ServerConfig {
5260    /// Default number of slab entries per connection. Set to 4 to accommodate
5261    /// H2 multiplexing (1 frontend + up to 3 backend connections per
5262    /// frontend with stream multiplexing). Previous value was 2 for H1-only
5263    /// operation. Operators with topologies that fan out across more
5264    /// clusters per session can override via `slab_entries_per_connection`
5265    /// in the config (clamped to [2, 32]).
5266    pub const DEFAULT_SLAB_ENTRIES_PER_CONNECTION: u64 = 4;
5267    /// Lower bound for the runtime knob. Below 2 the slab cannot hold one
5268    /// frontend + one backend per session.
5269    pub const MIN_SLAB_ENTRIES_PER_CONNECTION: u64 = 2;
5270    /// Upper bound for the runtime knob. 32 caps memory blow-up from a
5271    /// runaway config; 32 backends per frontend covers any sane topology.
5272    pub const MAX_SLAB_ENTRIES_PER_CONNECTION: u64 = 32;
5273
5274    /// Effective slab-entries-per-connection. Applies the [MIN, MAX] clamp
5275    /// and falls back to the default when the proto field is absent or 0.
5276    pub fn effective_slab_entries_per_connection(&self) -> u64 {
5277        let effective = match self.slab_entries_per_connection {
5278            Some(0) | None => Self::DEFAULT_SLAB_ENTRIES_PER_CONNECTION,
5279            Some(n) => n.clamp(
5280                Self::MIN_SLAB_ENTRIES_PER_CONNECTION,
5281                Self::MAX_SLAB_ENTRIES_PER_CONNECTION,
5282            ),
5283        };
5284        // POST: the effective value is always inside the documented clamp
5285        // window [MIN, MAX], regardless of the raw config input. The default
5286        // itself sits inside that window, so every branch satisfies the bound.
5287        debug_assert!(
5288            (Self::MIN_SLAB_ENTRIES_PER_CONNECTION..=Self::MAX_SLAB_ENTRIES_PER_CONNECTION)
5289                .contains(&effective),
5290            "effective slab entries per connection must stay within [MIN, MAX]"
5291        );
5292        effective
5293    }
5294
5295    /// Size of the slab for the Session manager.
5296    ///
5297    /// With HTTP/2 multiplexing, each frontend session can have multiple backend
5298    /// connections (one per cluster), so we allocate
5299    /// [`Self::effective_slab_entries_per_connection`] entries per connection
5300    /// instead of the old H1-only multiplier of 2.
5301    pub fn slab_capacity(&self) -> u64 {
5302        let per_conn = self.effective_slab_entries_per_connection();
5303        let capacity = 10 + per_conn * self.max_connections;
5304        // POST: the slab always reserves the 10-entry base (listeners, command
5305        // channel, etc.) plus at least MIN entries per connection, so it can
5306        // never be smaller than the base. Strict `>` for any non-zero
5307        // max_connections since per_conn >= MIN >= 2.
5308        debug_assert!(
5309            capacity >= 10,
5310            "slab capacity must reserve the base entries"
5311        );
5312        debug_assert!(
5313            self.max_connections == 0 || capacity > 10,
5314            "a non-zero connection cap must reserve per-connection slab entries"
5315        );
5316        capacity
5317    }
5318}
5319
5320/// reduce the config to the bare minimum needed by a worker
5321impl From<&Config> for ServerConfig {
5322    fn from(config: &Config) -> Self {
5323        let metrics = config.metrics.clone().map(|m| ServerMetricsConfig {
5324            address: m.address.to_string(),
5325            tagged_metrics: m.tagged_metrics,
5326            prefix: m.prefix,
5327            detail: Some(MetricDetail::from(m.detail) as i32),
5328        });
5329        let server_config = Self {
5330            max_connections: config.max_connections as u64,
5331            front_timeout: config.front_timeout,
5332            back_timeout: config.back_timeout,
5333            connect_timeout: config.connect_timeout,
5334            zombie_check_interval: config.zombie_check_interval,
5335            accept_queue_timeout: config.accept_queue_timeout,
5336            min_buffers: config.min_buffers,
5337            max_buffers: config.max_buffers,
5338            buffer_size: config.buffer_size,
5339            log_level: config.log_level.clone(),
5340            log_target: config.log_target.clone(),
5341            access_logs_target: config.access_logs_target.clone(),
5342            audit_logs_target: config.audit_logs_target.clone(),
5343            audit_logs_json_target: config.audit_logs_json_target.clone(),
5344            command_buffer_size: config.command_buffer_size,
5345            max_command_buffer_size: config.max_command_buffer_size,
5346            metrics,
5347            access_log_format: ProtobufAccessLogFormat::from(&config.access_logs_format) as i32,
5348            log_colored: config.log_colored,
5349            slab_entries_per_connection: config.slab_entries_per_connection,
5350            basic_auth_max_credential_bytes: config.basic_auth_max_credential_bytes,
5351            evict_on_queue_full: Some(config.evict_on_queue_full),
5352            max_connections_per_ip: Some(config.max_connections_per_ip),
5353            max_connections_per_subnet: Some(config.max_connections_per_subnet),
5354            subnet_ipv4_prefix: Some(config.subnet_ipv4_prefix),
5355            subnet_ipv6_prefix: Some(config.subnet_ipv6_prefix),
5356            retry_after: Some(config.retry_after),
5357            splice_pipe_capacity_bytes: config.splice_pipe_capacity_bytes,
5358            max_connection_attempts: Some(config.max_connection_attempts),
5359        };
5360
5361        // POST: the worker-facing config preserves the buffer-pool invariant
5362        // (min <= max) and carries the sizing knobs through unchanged — a
5363        // worker derives its slab and buffer pool straight from these, so any
5364        // drift here would desynchronize the master's view from the worker's.
5365        debug_assert!(
5366            server_config.min_buffers <= server_config.max_buffers,
5367            "ServerConfig must preserve min_buffers <= max_buffers"
5368        );
5369        debug_assert_eq!(
5370            server_config.buffer_size, config.buffer_size,
5371            "ServerConfig buffer_size must mirror the source config"
5372        );
5373        debug_assert_eq!(
5374            server_config.max_connections, config.max_connections as u64,
5375            "ServerConfig max_connections must mirror the source config"
5376        );
5377        server_config
5378    }
5379}
5380
5381#[cfg(test)]
5382mod tests {
5383    use toml::to_string;
5384
5385    use super::*;
5386
5387    #[test]
5388    fn http_frontend_debug_redacts_pem_material() {
5389        const CERTIFICATE_SECRET: &str = "HTTP_FRONTEND_CERTIFICATE_PEM_SECRET_SENTINEL";
5390        const CHAIN_SECRET: &str = "HTTP_FRONTEND_CHAIN_PEM_SECRET_SENTINEL";
5391        const KEY_SECRET: &str = "HTTP_FRONTEND_KEY_PEM_SECRET_SENTINEL";
5392        const HOSTNAME_SECRET: &str = "HTTP_FRONTEND_HOSTNAME_SECRET_SENTINEL";
5393        const PATH_SECRET: &str = "HTTP_FRONTEND_PATH_SECRET_SENTINEL";
5394        const METHOD_SECRET: &str = "HTTP_FRONTEND_METHOD_SECRET_SENTINEL";
5395        const TAG_KEY_SECRET: &str = "HTTP_FRONTEND_TAG_KEY_SECRET_SENTINEL";
5396        const TAG_SECRET: &str = "HTTP_FRONTEND_TAG_VALUE_SECRET_SENTINEL";
5397        const HEADER_KEY_SECRET: &str = "HTTP_FRONTEND_HEADER_KEY_SECRET_SENTINEL";
5398        const HEADER_SECRET: &str = "HTTP_FRONTEND_HEADER_VALUE_SECRET_SENTINEL";
5399        const REDIRECT_TEMPLATE_SECRET: &str = "HTTP_FRONTEND_REDIRECT_TEMPLATE_SECRET_SENTINEL";
5400        const REWRITE_HOST_SECRET: &str = "HTTP_FRONTEND_REWRITE_HOST_SECRET_SENTINEL";
5401        const REWRITE_PATH_SECRET: &str = "HTTP_FRONTEND_REWRITE_PATH_SECRET_SENTINEL";
5402
5403        let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
5404        let certificate = long_value(CERTIFICATE_SECRET);
5405        let certificate_chain = long_value(CHAIN_SECRET);
5406        let key = long_value(KEY_SECRET);
5407        let hostname = long_value(HOSTNAME_SECRET);
5408        let path = long_value(PATH_SECRET);
5409        let method = long_value(METHOD_SECRET);
5410        let redirect_template = long_value(REDIRECT_TEMPLATE_SECRET);
5411        let rewrite_host = long_value(REWRITE_HOST_SECRET);
5412        let rewrite_path = long_value(REWRITE_PATH_SECRET);
5413
5414        let frontend = HttpFrontendConfig {
5415            address: "127.0.0.1:8443".parse().unwrap(),
5416            hostname,
5417            path: PathRule::prefix(path),
5418            method: Some(method),
5419            certificate: Some(certificate),
5420            key: Some(key),
5421            certificate_chain: Some(vec![certificate_chain]),
5422            tls_versions: vec![TlsVersion::TlsV13],
5423            position: RulePosition::Tree,
5424            tags: Some(BTreeMap::from([(
5425                long_value(TAG_KEY_SECRET),
5426                long_value(TAG_SECRET),
5427            )])),
5428            redirect: None,
5429            redirect_scheme: None,
5430            redirect_template: Some(redirect_template),
5431            rewrite_host: Some(rewrite_host),
5432            rewrite_path: Some(rewrite_path),
5433            rewrite_port: None,
5434            required_auth: None,
5435            headers: vec![Header {
5436                position: HeaderPosition::Request as i32,
5437                key: long_value(HEADER_KEY_SECRET),
5438                val: long_value(HEADER_SECRET),
5439            }],
5440            hsts: None,
5441        };
5442
5443        let output = format!("{frontend:?}");
5444
5445        let secrets = [
5446            CERTIFICATE_SECRET,
5447            CHAIN_SECRET,
5448            KEY_SECRET,
5449            HOSTNAME_SECRET,
5450            PATH_SECRET,
5451            METHOD_SECRET,
5452            TAG_KEY_SECRET,
5453            TAG_SECRET,
5454            HEADER_KEY_SECRET,
5455            HEADER_SECRET,
5456            REDIRECT_TEMPLATE_SECRET,
5457            REWRITE_HOST_SECRET,
5458            REWRITE_PATH_SECRET,
5459        ];
5460        for secret in secrets {
5461            assert!(
5462                !output.contains(secret),
5463                "HttpFrontendConfig Debug leaked secret marker {secret}: {output}"
5464            );
5465        }
5466        let expected_metadata = [
5467            "address: 127.0.0.1:8443".to_owned(),
5468            format!("hostname_len: {}", long_value(HOSTNAME_SECRET).len()),
5469            format!("path_kind: {}", PathRule::prefix(String::new()).kind),
5470            format!("path_len: {}", long_value(PATH_SECRET).len()),
5471            format!("method_len: Some({})", long_value(METHOD_SECRET).len()),
5472            "certificate: Some(\"[redacted]\")".to_owned(),
5473            format!(
5474                "certificate_len: Some({})",
5475                long_value(CERTIFICATE_SECRET).len()
5476            ),
5477            "key: Some(\"[redacted]\")".to_owned(),
5478            format!("key_len: Some({})", long_value(KEY_SECRET).len()),
5479            "certificate_chain: Some(\"[redacted]\")".to_owned(),
5480            "certificate_chain_count: Some(1)".to_owned(),
5481            format!(
5482                "certificate_chain_len: Some({})",
5483                long_value(CHAIN_SECRET).len()
5484            ),
5485            "tls_versions_count: 1".to_owned(),
5486            "tags_count: 1".to_owned(),
5487            format!(
5488                "redirect_template_len: Some({})",
5489                long_value(REDIRECT_TEMPLATE_SECRET).len()
5490            ),
5491            format!(
5492                "rewrite_host_len: Some({})",
5493                long_value(REWRITE_HOST_SECRET).len()
5494            ),
5495            format!(
5496                "rewrite_path_len: Some({})",
5497                long_value(REWRITE_PATH_SECRET).len()
5498            ),
5499            "headers_count: 1".to_owned(),
5500        ];
5501        for safe_metadata in expected_metadata {
5502            assert!(
5503                output.contains(&safe_metadata),
5504                "HttpFrontendConfig Debug omitted safe metadata {safe_metadata}: {output}"
5505            );
5506        }
5507        assert!(
5508            output.len() <= 1024,
5509            "HttpFrontendConfig Debug output is not bounded: {} bytes",
5510            output.len()
5511        );
5512
5513        for (index, request) in frontend
5514            .generate_requests("safe-cluster-id")
5515            .into_iter()
5516            .enumerate()
5517        {
5518            let generated_output = format!("{request:?}");
5519            for secret in secrets {
5520                assert!(
5521                    !generated_output.contains(secret),
5522                    "generated frontend request {index} Debug leaked secret marker {secret}: {generated_output}"
5523                );
5524            }
5525            assert!(
5526                generated_output.len() <= 2048,
5527                "generated frontend request {index} Debug output is not bounded: {} bytes",
5528                generated_output.len()
5529            );
5530        }
5531    }
5532
5533    #[test]
5534    fn hsts_to_proto_enabled_substitutes_default_max_age() {
5535        let cfg = FileHstsConfig {
5536            enabled: Some(true),
5537            max_age: None,
5538            include_subdomains: None,
5539            preload: None,
5540            force_replace_backend: None,
5541        };
5542        let proto = cfg.to_proto("test").expect("should validate");
5543        assert_eq!(proto.enabled, Some(true));
5544        assert_eq!(proto.max_age, Some(DEFAULT_HSTS_MAX_AGE));
5545    }
5546
5547    #[test]
5548    fn hsts_to_proto_explicit_max_age_kept() {
5549        let cfg = FileHstsConfig {
5550            enabled: Some(true),
5551            max_age: Some(63_072_000),
5552            include_subdomains: Some(true),
5553            preload: Some(true),
5554            force_replace_backend: None,
5555        };
5556        let proto = cfg.to_proto("test").expect("should validate");
5557        assert_eq!(proto.max_age, Some(63_072_000));
5558        assert_eq!(proto.include_subdomains, Some(true));
5559        assert_eq!(proto.preload, Some(true));
5560    }
5561
5562    #[test]
5563    fn hsts_to_proto_disabled_keeps_zero_intent() {
5564        // `enabled = false` means "explicit disable" — the materialiser
5565        // in `Frontend::new` won't append an edit, so the proto still
5566        // round-trips with `enabled = Some(false)`.
5567        let cfg = FileHstsConfig {
5568            enabled: Some(false),
5569            max_age: None,
5570            include_subdomains: None,
5571            preload: None,
5572            force_replace_backend: None,
5573        };
5574        let proto = cfg.to_proto("test").expect("should validate");
5575        assert_eq!(proto.enabled, Some(false));
5576    }
5577
5578    #[test]
5579    fn hsts_to_proto_kill_switch_max_age_zero_allowed() {
5580        // RFC 6797 §11.4: `max-age=0` instructs the UA to "cease
5581        // regarding the host as a Known HSTS Host". Explicit operator
5582        // intent — must NOT warn or fail.
5583        let cfg = FileHstsConfig {
5584            enabled: Some(true),
5585            max_age: Some(0),
5586            include_subdomains: None,
5587            preload: None,
5588            force_replace_backend: None,
5589        };
5590        let proto = cfg.to_proto("test").expect("kill-switch must validate");
5591        assert_eq!(proto.max_age, Some(0));
5592    }
5593
5594    #[test]
5595    fn hsts_to_proto_missing_enabled_errors() {
5596        let cfg = FileHstsConfig {
5597            enabled: None,
5598            max_age: Some(31_536_000),
5599            include_subdomains: None,
5600            preload: None,
5601            force_replace_backend: None,
5602        };
5603        match cfg.to_proto("test").unwrap_err() {
5604            ConfigError::HstsEnabledRequired(scope) => assert_eq!(scope, "test"),
5605            other => panic!("expected HstsEnabledRequired, got {other:?}"),
5606        }
5607    }
5608
5609    #[test]
5610    fn hsts_rejected_on_http_listener() {
5611        // RFC 6797 §7.2: an [hsts] block on an HTTP listener must be
5612        // rejected at TOML config-load — `HttpListenerConfig` carries no
5613        // `hsts` field and silently dropping the operator's intent
5614        // would be a worse failure mode than a typed error.
5615        let mut listener = ListenerBuilder::new(
5616            SocketAddress::new_v4(127, 0, 0, 1, 8080),
5617            ListenerProtocol::Http,
5618        );
5619        listener.hsts = Some(FileHstsConfig {
5620            enabled: Some(true),
5621            max_age: Some(31_536_000),
5622            include_subdomains: None,
5623            preload: None,
5624            force_replace_backend: None,
5625        });
5626        match listener.to_http(None).unwrap_err() {
5627            ConfigError::HstsOnPlainHttp(scope) => assert!(
5628                scope.contains("HTTP listener"),
5629                "expected scope to mention 'HTTP listener', got {scope:?}"
5630            ),
5631            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
5632        }
5633    }
5634
5635    /// Regression for sozu-proxy/sozu#1418: an H2 flood threshold of `0` set
5636    /// in the config file reached `HttpListenerConfig` untouched. From there
5637    /// `lib/src/http.rs::get_h2_flood_config` handed it to
5638    /// `H2FloodDetector::new`, whose `check_flood` compares `count >
5639    /// threshold` — so the first counted RST_STREAM of every H2 connection
5640    /// tripped the detector and the listener answered `ENHANCE_YOUR_CALM` to
5641    /// every client. `UpdateHttpListener` already refused the same value
5642    /// (`validate_h2_flood_knobs_http`); the config-file path did not.
5643    #[test]
5644    fn h2_flood_threshold_zero_rejected_on_http_listener() {
5645        let mut listener = ListenerBuilder::new(
5646            SocketAddress::new_v4(127, 0, 0, 1, 8080),
5647            ListenerProtocol::Http,
5648        );
5649        listener.h2_max_rst_stream_per_window = Some(0);
5650        match listener.to_http(None).unwrap_err() {
5651            ConfigError::H2ThresholdBelowMinimum {
5652                key,
5653                value,
5654                minimum,
5655                ..
5656            } => {
5657                assert_eq!(key, "h2_max_rst_stream_per_window");
5658                assert_eq!(value, 0);
5659                assert_eq!(minimum, 1);
5660            }
5661            other => panic!("expected H2ThresholdBelowMinimum, got {other:?}"),
5662        }
5663    }
5664
5665    /// The HTTPS listener path (`to_tls`) carries the same knobs and needs the
5666    /// same gate (sozu-proxy/sozu#1418). A lifetime cap is used here rather
5667    /// than a per-window one so the `u64` knobs are covered too.
5668    #[test]
5669    fn h2_flood_threshold_zero_rejected_on_https_listener() {
5670        let mut listener = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
5671        listener.h2_max_rst_stream_abusive_lifetime = Some(0);
5672        match listener.to_tls(None).unwrap_err() {
5673            ConfigError::H2ThresholdBelowMinimum {
5674                key,
5675                value,
5676                minimum,
5677                ..
5678            } => {
5679                assert_eq!(key, "h2_max_rst_stream_abusive_lifetime");
5680                assert_eq!(value, 0);
5681                assert_eq!(minimum, 1);
5682            }
5683            other => panic!("expected H2ThresholdBelowMinimum, got {other:?}"),
5684        }
5685    }
5686
5687    /// `h2_stream_shrink_ratio` has a runtime floor of 2, not 1
5688    /// (`lib/src/protocol/mux/h2.rs`'s `.max(2)`), and
5689    /// `validate_h2_flood_knobs_http` rejects `< 2` on the update path. The
5690    /// config-file path must agree (sozu-proxy/sozu#1418).
5691    #[test]
5692    fn h2_stream_shrink_ratio_below_two_rejected_on_http_listener() {
5693        let mut listener = ListenerBuilder::new(
5694            SocketAddress::new_v4(127, 0, 0, 1, 8080),
5695            ListenerProtocol::Http,
5696        );
5697        listener.h2_stream_shrink_ratio = Some(1);
5698        match listener.to_http(None).unwrap_err() {
5699            ConfigError::H2ThresholdBelowMinimum {
5700                key,
5701                value,
5702                minimum,
5703                ..
5704            } => {
5705                assert_eq!(key, "h2_stream_shrink_ratio");
5706                assert_eq!(value, 1);
5707                assert_eq!(minimum, 2);
5708            }
5709            other => panic!("expected H2ThresholdBelowMinimum, got {other:?}"),
5710        }
5711    }
5712
5713    /// The boundary itself must load: every documented minimum is an accepted
5714    /// value, not a rejected one.
5715    #[test]
5716    fn h2_thresholds_at_their_minimum_accepted() {
5717        let mut listener = ListenerBuilder::new(
5718            SocketAddress::new_v4(127, 0, 0, 1, 8080),
5719            ListenerProtocol::Http,
5720        );
5721        listener.h2_max_rst_stream_per_window = Some(1);
5722        listener.h2_max_rst_stream_abusive_lifetime = Some(1);
5723        listener.h2_stream_shrink_ratio = Some(2);
5724        let config = listener
5725            .to_http(None)
5726            .expect("thresholds at their minimum must be accepted");
5727        assert_eq!(config.h2_max_rst_stream_per_window, Some(1));
5728        assert_eq!(config.h2_max_rst_stream_abusive_lifetime, Some(1));
5729        assert_eq!(config.h2_stream_shrink_ratio, Some(2));
5730    }
5731
5732    #[test]
5733    fn hsts_rejected_on_http_frontend() {
5734        // A `FileClusterFrontendConfig` without a key+certificate pair
5735        // generates `RequestType::AddHttpFrontend` in
5736        // `HttpFrontendConfig::generate_requests`. RFC 6797 §7.2 forbids
5737        // HSTS on plaintext HTTP, so an `[hsts]` block on a cert-less
5738        // (HTTP-bound) frontend must be rejected at TOML config-load.
5739        let frontend = FileClusterFrontendConfig {
5740            address: "127.0.0.1:8080".parse().unwrap(),
5741            hostname: Some("example.com".to_owned()),
5742            alpn: vec![],
5743            path: None,
5744            path_type: None,
5745            method: None,
5746            certificate: None,
5747            key: None,
5748            certificate_chain: None,
5749            tls_versions: vec![],
5750            position: RulePosition::Tree,
5751            tags: None,
5752            redirect: None,
5753            redirect_scheme: None,
5754            redirect_template: None,
5755            rewrite_host: None,
5756            rewrite_path: None,
5757            rewrite_port: None,
5758            required_auth: None,
5759            headers: None,
5760            hsts: Some(FileHstsConfig {
5761                enabled: Some(true),
5762                max_age: Some(31_536_000),
5763                include_subdomains: None,
5764                preload: None,
5765                force_replace_backend: None,
5766            }),
5767        };
5768        match frontend.to_http_front("api").unwrap_err() {
5769            ConfigError::HstsOnPlainHttp(scope) => {
5770                assert!(
5771                    scope.contains("api") && scope.contains("example.com"),
5772                    "expected scope to mention 'api' and 'example.com', got {scope:?}"
5773                );
5774            }
5775            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
5776        }
5777    }
5778
5779    #[test]
5780    fn serialize() {
5781        let http = ListenerBuilder::new(
5782            SocketAddress::new_v4(127, 0, 0, 1, 8080),
5783            ListenerProtocol::Http,
5784        )
5785        .with_answer_404_path(Some("404.html"))
5786        .to_owned();
5787        println!("http: {:?}", to_string(&http));
5788
5789        let https = ListenerBuilder::new(
5790            SocketAddress::new_v4(127, 0, 0, 1, 8443),
5791            ListenerProtocol::Https,
5792        )
5793        .with_answer_404_path(Some("404.html"))
5794        .to_owned();
5795        println!("https: {:?}", to_string(&https));
5796
5797        let listeners = vec![http, https];
5798        let config = FileConfig {
5799            command_socket: Some(String::from("./command_folder/sock")),
5800            worker_count: Some(2),
5801            worker_automatic_restart: Some(true),
5802            max_connections: Some(500),
5803            min_buffers: Some(1),
5804            max_buffers: Some(500),
5805            buffer_size: Some(16393),
5806            metrics: Some(MetricsConfig {
5807                address: "127.0.0.1:8125".parse().unwrap(),
5808                tagged_metrics: false,
5809                prefix: Some(String::from("sozu-metrics")),
5810                detail: MetricDetailLevel::default(),
5811            }),
5812            listeners: Some(listeners),
5813            ..Default::default()
5814        };
5815
5816        println!("config: {:?}", to_string(&config));
5817        let encoded = to_string(&config).unwrap();
5818        println!("conf:\n{encoded}");
5819    }
5820
5821    #[test]
5822    fn parse() {
5823        let path = "assets/config.toml";
5824        let config = Config::load_from_path(path).unwrap_or_else(|load_error| {
5825            panic!("Cannot load config from path {path}: {load_error:?}")
5826        });
5827        println!("config: {config:#?}");
5828        //panic!();
5829    }
5830
5831    #[test]
5832    fn multiple_listeners_preserve_per_address_expect_proxy() {
5833        let toml_content = r#"
5834            command_socket = "/tmp/sozu_test.sock"
5835            worker_count = 1
5836
5837            [[listeners]]
5838            protocol = "http"
5839            address = "172.16.20.1:80"
5840            expect_proxy = true
5841
5842            [[listeners]]
5843            protocol = "http"
5844            address = "10.22.0.1:80"
5845            expect_proxy = false
5846
5847            [[listeners]]
5848            protocol = "https"
5849            address = "192.168.1.1:443"
5850            expect_proxy = true
5851
5852            [[listeners]]
5853            protocol = "https"
5854            address = "192.168.2.1:443"
5855            expect_proxy = false
5856        "#;
5857
5858        let file_config: FileConfig =
5859            toml::from_str(toml_content).expect("Could not parse TOML config");
5860
5861        let listeners = file_config.listeners.as_ref().expect("No listeners found");
5862        assert_eq!(listeners.len(), 4);
5863
5864        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
5865            .into_config()
5866            .expect("Could not build config");
5867
5868        assert_eq!(config.http_listeners.len(), 2);
5869        assert_eq!(config.https_listeners.len(), 2);
5870
5871        // HTTP listeners
5872        let http_proxy = config
5873            .http_listeners
5874            .iter()
5875            .find(|l| SocketAddr::from(l.address) == "172.16.20.1:80".parse().unwrap())
5876            .expect("Listener on 172.16.20.1:80 not found");
5877        let http_direct = config
5878            .http_listeners
5879            .iter()
5880            .find(|l| SocketAddr::from(l.address) == "10.22.0.1:80".parse().unwrap())
5881            .expect("Listener on 10.22.0.1:80 not found");
5882
5883        assert!(http_proxy.expect_proxy);
5884        assert!(!http_direct.expect_proxy);
5885
5886        // HTTPS listeners
5887        let https_proxy = config
5888            .https_listeners
5889            .iter()
5890            .find(|l| SocketAddr::from(l.address) == "192.168.1.1:443".parse().unwrap())
5891            .expect("Listener on 192.168.1.1:443 not found");
5892        let https_direct = config
5893            .https_listeners
5894            .iter()
5895            .find(|l| SocketAddr::from(l.address) == "192.168.2.1:443".parse().unwrap())
5896            .expect("Listener on 192.168.2.1:443 not found");
5897
5898        assert!(https_proxy.expect_proxy);
5899        assert!(!https_direct.expect_proxy);
5900    }
5901
5902    /// #524: `affinity_header` travels from the TOML file to the
5903    /// `AddCluster` order the workers receive.
5904    #[test]
5905    fn an_affinity_header_reaches_the_add_cluster_order() {
5906        let file: FileClusterConfig = toml::from_str(
5907            r#"
5908            protocol = "http"
5909            load_balancing = "HRW"
5910            affinity_header = "X-Tenant"
5911            frontends = []
5912            backends = []
5913            "#,
5914        )
5915        .expect("the cluster must parse");
5916        let ClusterConfig::Http(http) = file
5917            .to_cluster_config("keyed", &HashSet::new())
5918            .expect("the cluster must build")
5919        else {
5920            panic!("an http cluster must build as one");
5921        };
5922        let orders = http.generate_requests().expect("the orders must build");
5923        let Some(RequestType::AddCluster(cluster)) =
5924            orders.first().and_then(|order| order.request_type.as_ref())
5925        else {
5926            panic!("the orders must lead with an AddCluster");
5927        };
5928        assert_eq!(cluster.affinity_header.as_deref(), Some("X-Tenant"));
5929        assert_eq!(cluster.affinity_cookie, None);
5930    }
5931
5932    /// #524: a TCP cluster has no request to read a header or a cookie
5933    /// from, so declaring one is refused rather than silently ignored.
5934    #[test]
5935    fn a_tcp_cluster_refuses_an_affinity_header_or_cookie() {
5936        for key in [
5937            "affinity_header = \"X-Tenant\"",
5938            "affinity_cookie = \"tenant\"",
5939        ] {
5940            let file: FileClusterConfig = toml::from_str(&format!(
5941                "protocol = \"tcp\"\nload_balancing = \"MAGLEV\"\n{key}\nfrontends = []\nbackends = []\n"
5942            ))
5943            .expect("the cluster must parse");
5944            assert!(
5945                matches!(
5946                    file.to_cluster_config("keyed", &HashSet::new()),
5947                    Err(ConfigError::InvalidAffinityKey { .. })
5948                ),
5949                "a TCP cluster must refuse `{key}`"
5950            );
5951        }
5952    }
5953
5954    /// #524: at most one key source, and a name a request can carry.
5955    #[test]
5956    fn validate_affinity_key_accepts_one_token_and_refuses_the_rest() {
5957        assert!(validate_affinity_key(None, None).is_ok());
5958        assert!(validate_affinity_key(Some("X-Tenant"), None).is_ok());
5959        assert!(validate_affinity_key(None, Some("tenant_id")).is_ok());
5960        assert!(validate_affinity_key(Some("X-Tenant"), Some("tenant")).is_err());
5961        for unreachable in ["Host", "host", "HOST", "Cookie", "cookie"] {
5962            assert!(
5963                validate_affinity_key(Some(unreachable), None).is_err(),
5964                "header {unreachable:?} can never be found and must be refused"
5965            );
5966        }
5967        // A cookie merely NAMED like those headers is an ordinary cookie.
5968        assert!(validate_affinity_key(None, Some("host")).is_ok());
5969        for bad in ["", "X Tenant", "X-Tenant:", "tenant\r\n", "t=1"] {
5970            assert!(
5971                validate_affinity_key(Some(bad), None).is_err(),
5972                "header {bad:?} must be refused"
5973            );
5974            assert!(
5975                validate_affinity_key(None, Some(bad)).is_err(),
5976                "cookie {bad:?} must be refused"
5977            );
5978        }
5979    }
5980
5981    /// #524: shard knobs travel from the TOML file to the `AddCluster`
5982    /// order, and an invalid combination is refused at load.
5983    #[test]
5984    fn shuffle_sharding_reaches_the_add_cluster_order_and_is_validated() {
5985        let parse = |extra: &str| -> FileClusterConfig {
5986            toml::from_str(&format!(
5987                "protocol = \"http\"\nfrontends = []\nbackends = []\n{extra}"
5988            ))
5989            .expect("the cluster must parse")
5990        };
5991        let ClusterConfig::Http(http) =
5992            parse("shard_percent = 25\nshard_min_backends = 4\nshard_mode = \"STRICT\"\n")
5993                .to_cluster_config("sharded", &HashSet::new())
5994                .expect("a valid sharded cluster builds")
5995        else {
5996            panic!("an http cluster must build as one");
5997        };
5998        let orders = http.generate_requests().expect("the orders must build");
5999        let Some(RequestType::AddCluster(cluster)) =
6000            orders.first().and_then(|order| order.request_type.as_ref())
6001        else {
6002            panic!("the orders must lead with an AddCluster");
6003        };
6004        assert_eq!(
6005            (
6006                cluster.shard_percent,
6007                cluster.shard_min_backends,
6008                cluster.shard_mode
6009            ),
6010            (Some(25), Some(4), Some(ShardMode::Strict as i32))
6011        );
6012        for bad in [
6013            "shard_percent = 0\n",
6014            "shard_percent = 101\n",
6015            "shard_percent = 25\nshard_min_backends = 1\n",
6016            "shard_min_backends = 8\n",
6017            "shard_mode = \"STRICT\"\n",
6018        ] {
6019            assert!(
6020                matches!(
6021                    parse(bad).to_cluster_config("sharded", &HashSet::new()),
6022                    Err(ConfigError::InvalidShuffleSharding { .. })
6023                ),
6024                "{bad:?} must be refused"
6025            );
6026        }
6027    }
6028
6029    /// #1801: `accepted_statuses` entries parse to inclusive ranges, and
6030    /// everything outside the documented syntax or `100..=599` is refused.
6031    #[test]
6032    fn http_status_range_parses_codes_ranges_classes_and_any() {
6033        let range = |start, end| HttpStatusRange { start, end };
6034        for (spec, expected) in [
6035            ("404", range(404, 404)),
6036            ("200-399", range(200, 399)),
6037            (" 200 - 399 ", range(200, 399)),
6038            ("2xx", range(200, 299)),
6039            ("5XX", range(500, 599)),
6040            ("any", range(100, 599)),
6041            ("ANY", range(100, 599)),
6042            ("100-599", range(100, 599)),
6043        ] {
6044            assert_eq!(parse_http_status_range(spec), Ok(expected), "{spec:?}");
6045        }
6046        for spec in [
6047            "", "abc", "99", "600", "0", "6xx", "0xx", "2x", "2xxx", "399-200", "200-", "-200",
6048            "+200", "200-600", "1-2-3", "2xx-3xx",
6049        ] {
6050            assert!(
6051                parse_http_status_range(spec).is_err(),
6052                "{spec:?} must be refused"
6053            );
6054        }
6055    }
6056
6057    /// #1801: the rendering used by `sozu cluster health-check list` parses
6058    /// back to the same range.
6059    #[test]
6060    fn http_status_range_display_round_trips_through_the_parser() {
6061        for spec in ["404", "200-399", "2xx", "any", "101-102"] {
6062            let range = parse_http_status_range(spec).unwrap();
6063            assert_eq!(range.to_string(), spec);
6064            assert_eq!(parse_http_status_range(&range.to_string()), Ok(range));
6065        }
6066    }
6067
6068    /// #1801: validation of the probe mode, the TCP-mode URI exemption, the
6069    /// range bounds and the `expected_status` / `accepted_statuses`
6070    /// exclusion — the rules shared by the CLI, the TOML loader, the master
6071    /// state and the worker.
6072    #[test]
6073    fn health_check_validation_covers_mode_uri_and_accepted_statuses() {
6074        let http = HealthCheckConfig {
6075            uri: "/health".to_owned(),
6076            interval: 10,
6077            timeout: 5,
6078            healthy_threshold: 3,
6079            unhealthy_threshold: 3,
6080            expected_status: 0,
6081            mode: HealthCheckMode::Http as i32,
6082            accepted_statuses: Vec::new(),
6083        };
6084        assert_eq!(validate_health_check_config(&http), Ok(()));
6085
6086        // TCP mode ignores the URI, so an empty one is fine; HTTP mode is not.
6087        let tcp_no_uri = HealthCheckConfig {
6088            uri: String::new(),
6089            mode: HealthCheckMode::Tcp as i32,
6090            ..http.clone()
6091        };
6092        assert_eq!(validate_health_check_config(&tcp_no_uri), Ok(()));
6093        let http_no_uri = HealthCheckConfig {
6094            uri: String::new(),
6095            ..http.clone()
6096        };
6097        assert!(validate_health_check_config(&http_no_uri).is_err());
6098        // Control bytes are refused in every mode.
6099        let tcp_crlf = HealthCheckConfig {
6100            uri: "/a\r\nGET /b".to_owned(),
6101            ..tcp_no_uri.clone()
6102        };
6103        assert!(validate_health_check_config(&tcp_crlf).is_err());
6104
6105        // TCP mode judges no status, so status fields beside it are refused.
6106        let tcp_expected = HealthCheckConfig {
6107            expected_status: 200,
6108            ..tcp_no_uri.clone()
6109        };
6110        assert!(validate_health_check_config(&tcp_expected).is_err());
6111        let tcp_accepted = HealthCheckConfig {
6112            accepted_statuses: vec![HttpStatusRange {
6113                start: 100,
6114                end: 599,
6115            }],
6116            ..tcp_no_uri.clone()
6117        };
6118        assert!(validate_health_check_config(&tcp_accepted).is_err());
6119
6120        let unknown_mode = HealthCheckConfig {
6121            mode: 7,
6122            ..http.clone()
6123        };
6124        assert!(validate_health_check_config(&unknown_mode).is_err());
6125
6126        let accepted = HealthCheckConfig {
6127            accepted_statuses: vec![HttpStatusRange {
6128                start: 200,
6129                end: 499,
6130            }],
6131            ..http.clone()
6132        };
6133        assert_eq!(validate_health_check_config(&accepted), Ok(()));
6134        let both = HealthCheckConfig {
6135            expected_status: 200,
6136            ..accepted.clone()
6137        };
6138        assert!(
6139            validate_health_check_config(&both).is_err(),
6140            "expected_status and accepted_statuses must not both be set"
6141        );
6142        for (start, end) in [(99, 200), (200, 600), (300, 200)] {
6143            let bad_range = HealthCheckConfig {
6144                accepted_statuses: vec![HttpStatusRange { start, end }],
6145                ..http.clone()
6146            };
6147            assert!(
6148                validate_health_check_config(&bad_range).is_err(),
6149                "{start}-{end} must be refused"
6150            );
6151        }
6152    }
6153
6154    /// #1801: the TOML block defaults to HTTP mode on `/`, accepts
6155    /// `mode = "TCP"` without a `uri`, carries `accepted_statuses` to the
6156    /// `AddCluster` order, and refuses a bad block at load.
6157    #[test]
6158    fn health_check_toml_mode_and_accepted_statuses_reach_the_add_cluster_order() {
6159        let parse = |block: &str| -> FileClusterConfig {
6160            toml::from_str(&format!(
6161                "protocol = \"http\"\nfrontends = []\nbackends = []\n[health_check]\n{block}"
6162            ))
6163            .expect("the cluster must parse")
6164        };
6165        let health_check = |block: &str| -> HealthCheckConfig {
6166            let ClusterConfig::Http(http) = parse(block)
6167                .to_cluster_config("checked", &HashSet::new())
6168                .expect("a valid health check builds")
6169            else {
6170                panic!("an http cluster must build as one");
6171            };
6172            let orders = http.generate_requests().expect("the orders must build");
6173            let Some(RequestType::AddCluster(cluster)) =
6174                orders.first().and_then(|order| order.request_type.as_ref())
6175            else {
6176                panic!("the orders must lead with an AddCluster");
6177            };
6178            cluster
6179                .health_check
6180                .clone()
6181                .expect("the health check must travel")
6182        };
6183
6184        // A legacy block keeps its meaning: HTTP mode, no list.
6185        let legacy = health_check("uri = \"/health\"\nexpected_status = 204\n");
6186        assert_eq!(legacy.mode, HealthCheckMode::Http as i32);
6187        assert_eq!(
6188            (legacy.uri.as_str(), legacy.expected_status),
6189            ("/health", 204)
6190        );
6191        assert!(legacy.accepted_statuses.is_empty());
6192
6193        let defaulted = health_check("");
6194        assert_eq!(defaulted.mode, HealthCheckMode::Http as i32);
6195        assert_eq!(defaulted.uri, "/");
6196
6197        let tcp = health_check("mode = \"TCP\"\n");
6198        assert_eq!(tcp.mode, HealthCheckMode::Tcp as i32);
6199
6200        let accepted = health_check("uri = \"/livez\"\naccepted_statuses = [\"2xx\", \"404\"]\n");
6201        assert_eq!(
6202            accepted.accepted_statuses,
6203            vec![
6204                HttpStatusRange {
6205                    start: 200,
6206                    end: 299
6207                },
6208                HttpStatusRange {
6209                    start: 404,
6210                    end: 404
6211                },
6212            ]
6213        );
6214
6215        for bad in [
6216            "accepted_statuses = [\"6xx\"]\n",
6217            "expected_status = 200\naccepted_statuses = [\"any\"]\n",
6218            "uri = \"health\"\n",
6219            "mode = \"TCP\"\nexpected_status = 200\n",
6220            "mode = \"TCP\"\naccepted_statuses = [\"any\"]\n",
6221        ] {
6222            assert!(
6223                matches!(
6224                    parse(bad).to_cluster_config("checked", &HashSet::new()),
6225                    Err(ConfigError::InvalidHealthCheck { .. })
6226                ),
6227                "{bad:?} must be refused"
6228            );
6229        }
6230    }
6231
6232    #[test]
6233    fn multiple_listeners_generate_correct_worker_requests() {
6234        let toml_content = r#"
6235            command_socket = "/tmp/sozu_test.sock"
6236            worker_count = 1
6237            activate_listeners = true
6238
6239            [[listeners]]
6240            protocol = "http"
6241            address = "172.16.20.1:80"
6242            expect_proxy = true
6243
6244            [[listeners]]
6245            protocol = "http"
6246            address = "10.22.0.1:80"
6247            expect_proxy = false
6248        "#;
6249
6250        let file_config: FileConfig =
6251            toml::from_str(toml_content).expect("Could not parse TOML config");
6252
6253        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
6254            .into_config()
6255            .expect("Could not build config");
6256
6257        let messages = config
6258            .generate_config_messages()
6259            .expect("Could not generate config messages");
6260
6261        let add_listener_count = messages
6262            .iter()
6263            .filter(|m| {
6264                matches!(
6265                    m.content.request_type,
6266                    Some(RequestType::AddHttpListener(_))
6267                )
6268            })
6269            .count();
6270
6271        let activate_listener_count = messages
6272            .iter()
6273            .filter(|m| {
6274                matches!(
6275                    m.content.request_type,
6276                    Some(RequestType::ActivateListener(ActivateListener {
6277                        proxy,
6278                        ..
6279                    })) if proxy == ListenerType::Http as i32
6280                )
6281            })
6282            .count();
6283
6284        assert_eq!(add_listener_count, 2);
6285        assert_eq!(activate_listener_count, 2);
6286    }
6287
6288    #[test]
6289    fn documented_udp_dns_example_loads_and_emits_udp_requests() {
6290        // The DNS example from doc/configure.md ("#### UDP clusters"): a
6291        // `protocol = "udp"` listener + a `protocol = "tcp"` cluster whose
6292        // frontend points at that UDP listener address, with datagram knobs
6293        // under `[clusters.dns.udp]`. This must load without a
6294        // `WrongFrontendProtocol` error and emit an `AddUdpListener` and an
6295        // `AddUdpFrontend` (not `AddTcpFrontend`).
6296        let toml_content = r#"
6297            command_socket = "/tmp/sozu_test.sock"
6298            worker_count = 1
6299            activate_listeners = true
6300
6301            [[listeners]]
6302            protocol = "udp"
6303            address  = "0.0.0.0:53"
6304
6305            [clusters.dns]
6306            protocol       = "tcp"
6307            load_balancing = "HRW"
6308            frontends = [
6309              { address = "0.0.0.0:53" }
6310            ]
6311            backends = [
6312              { address = "10.0.0.10:53" },
6313              { address = "10.0.0.11:53" }
6314            ]
6315
6316            [clusters.dns.udp]
6317            affinity_key        = "SOURCE_IP"
6318            responses           = 1
6319            requests            = 0
6320            send_proxy_protocol = true
6321
6322            [clusters.dns.udp.health]
6323            mode      = "TCP_PROBE"
6324            tcp_port  = 53
6325            rise      = 2
6326            fall      = 3
6327            fail_open = true
6328        "#;
6329
6330        let file_config: FileConfig =
6331            toml::from_str(toml_content).expect("Could not parse documented DNS TOML");
6332
6333        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
6334            .into_config()
6335            .expect("documented UDP DNS example must load without WrongFrontendProtocol");
6336
6337        // The UDP listener was registered.
6338        assert_eq!(
6339            config.udp_listeners.len(),
6340            1,
6341            "the protocol=\"udp\" listener must be built"
6342        );
6343
6344        let messages = config
6345            .generate_config_messages()
6346            .expect("Could not generate config messages");
6347
6348        let add_udp_listener_count = messages
6349            .iter()
6350            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpListener(_))))
6351            .count();
6352        assert_eq!(
6353            add_udp_listener_count, 1,
6354            "must emit exactly one AddUdpListener"
6355        );
6356
6357        let add_udp_frontend_count = messages
6358            .iter()
6359            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpFrontend(_))))
6360            .count();
6361        assert_eq!(
6362            add_udp_frontend_count, 1,
6363            "the cluster frontend on the UDP listener must emit AddUdpFrontend"
6364        );
6365
6366        // It must NOT have been emitted as a TCP frontend.
6367        let add_tcp_frontend_count = messages
6368            .iter()
6369            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddTcpFrontend(_))))
6370            .count();
6371        assert_eq!(
6372            add_tcp_frontend_count, 0,
6373            "a UDP-listener-addressed frontend must not be emitted as AddTcpFrontend"
6374        );
6375
6376        // The AddUdpFrontend carries the cluster id and address.
6377        let udp_frontend = messages
6378            .iter()
6379            .find_map(|m| match &m.content.request_type {
6380                Some(RequestType::AddUdpFrontend(f)) => Some(f),
6381                _ => None,
6382            })
6383            .expect("AddUdpFrontend must be present");
6384        assert_eq!(udp_frontend.cluster_id, "dns");
6385        assert_eq!(
6386            SocketAddr::from(udp_frontend.address),
6387            "0.0.0.0:53".parse().unwrap()
6388        );
6389
6390        // The UDP cluster knobs from [clusters.dns.udp] survive onto the
6391        // AddCluster request.
6392        let cluster = messages
6393            .iter()
6394            .find_map(|m| match &m.content.request_type {
6395                Some(RequestType::AddCluster(c)) if c.cluster_id == "dns" => Some(c),
6396                _ => None,
6397            })
6398            .expect("AddCluster for 'dns' must be present");
6399        let udp = cluster
6400            .udp
6401            .as_ref()
6402            .expect("[clusters.dns.udp] block must carry onto the cluster");
6403        assert_eq!(udp.responses, Some(1));
6404    }
6405
6406    /// The `interface` key round-trips from the TOML file into every
6407    /// listener config and its activation, and a listener that omits it keeps
6408    /// `interface = None` — today's behaviour. Two listeners may share an
6409    /// address on different interfaces; the same (address, interface) twice is
6410    /// still a duplicate.
6411    #[test]
6412    fn listener_interface_round_trips_from_the_config_file() {
6413        let toml_content = r#"
6414            command_socket = "/tmp/sozu_test.sock"
6415            worker_count = 1
6416            activate_listeners = true
6417
6418            [[listeners]]
6419            protocol = "http"
6420            address = "0.0.0.0:8080"
6421            interface = "wg0"
6422
6423            [[listeners]]
6424            protocol = "http"
6425            address = "0.0.0.0:8080"
6426            interface = "lo"
6427
6428            [[listeners]]
6429            protocol = "http"
6430            address = "0.0.0.0:8081"
6431
6432            [[listeners]]
6433            protocol = "https"
6434            address = "0.0.0.0:8443"
6435            interface = "wg0"
6436
6437            [[listeners]]
6438            protocol = "tcp"
6439            address = "0.0.0.0:9000"
6440            interface = "wg0"
6441
6442            [[listeners]]
6443            protocol = "udp"
6444            address = "0.0.0.0:5353"
6445            interface = "wg0"
6446        "#;
6447
6448        let file_config: FileConfig =
6449            toml::from_str(toml_content).expect("the interface key must parse");
6450        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
6451            .into_config()
6452            .expect("listeners on one address with different interfaces must build");
6453
6454        let http: Vec<(String, Option<&str>)> = config
6455            .http_listeners
6456            .iter()
6457            .map(|l| {
6458                (
6459                    SocketAddr::from(l.address).to_string(),
6460                    l.interface.as_deref(),
6461                )
6462            })
6463            .collect();
6464        assert_eq!(
6465            http,
6466            vec![
6467                ("0.0.0.0:8080".to_owned(), Some("wg0")),
6468                ("0.0.0.0:8080".to_owned(), Some("lo")),
6469                ("0.0.0.0:8081".to_owned(), None),
6470            ]
6471        );
6472        assert_eq!(config.https_listeners[0].interface.as_deref(), Some("wg0"));
6473        assert_eq!(config.tcp_listeners[0].interface.as_deref(), Some("wg0"));
6474        assert_eq!(config.udp_listeners[0].interface.as_deref(), Some("wg0"));
6475
6476        let activations: Vec<Option<String>> = config
6477            .generate_config_messages()
6478            .expect("generate the config messages")
6479            .into_iter()
6480            .filter_map(|request| match request.content.request_type {
6481                Some(RequestType::ActivateListener(activate)) => Some(activate.interface),
6482                _ => None,
6483            })
6484            .collect();
6485        assert_eq!(
6486            activations,
6487            vec![
6488                Some("wg0".to_owned()),
6489                Some("lo".to_owned()),
6490                None,
6491                Some("wg0".to_owned()),
6492                Some("wg0".to_owned()),
6493                Some("wg0".to_owned()),
6494            ]
6495        );
6496
6497        let duplicate = r#"
6498            command_socket = "/tmp/sozu_test.sock"
6499            worker_count = 1
6500
6501            [[listeners]]
6502            protocol = "http"
6503            address = "0.0.0.0:8080"
6504            interface = "wg0"
6505
6506            [[listeners]]
6507            protocol = "http"
6508            address = "0.0.0.0:8080"
6509            interface = "wg0"
6510        "#;
6511        let file_config: FileConfig = toml::from_str(duplicate).expect("parse");
6512        assert!(
6513            ConfigBuilder::new(file_config, "/tmp/test_config.toml")
6514                .into_config()
6515                .is_err(),
6516            "the same (address, interface) twice must be refused"
6517        );
6518    }
6519
6520    /// An interface name the kernel would refuse is reported at config load,
6521    /// naming the listener and the interface.
6522    #[test]
6523    fn invalid_listener_interface_rejected_at_config_load() {
6524        let toml_content = r#"
6525            command_socket = "/tmp/sozu_test.sock"
6526            worker_count = 1
6527
6528            [[listeners]]
6529            protocol = "http"
6530            address = "0.0.0.0:8080"
6531            interface = "wg/0"
6532        "#;
6533        let file_config: FileConfig = toml::from_str(toml_content).expect("parse");
6534        let error = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
6535            .into_config()
6536            .expect_err("an invalid interface name must be refused");
6537        let message = error.to_string();
6538        assert!(message.contains("0.0.0.0:8080"), "{message}");
6539        assert!(message.contains("wg/0"), "{message}");
6540    }
6541
6542    #[test]
6543    fn duplicate_listener_address_rejected() {
6544        let toml_content = r#"
6545            command_socket = "/tmp/sozu_test.sock"
6546            worker_count = 1
6547
6548            [[listeners]]
6549            protocol = "http"
6550            address = "0.0.0.0:80"
6551
6552            [[listeners]]
6553            protocol = "http"
6554            address = "0.0.0.0:80"
6555        "#;
6556
6557        let file_config: FileConfig =
6558            toml::from_str(toml_content).expect("Could not parse TOML config");
6559
6560        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
6561
6562        assert!(
6563            result.is_err(),
6564            "Should reject duplicate listener addresses"
6565        );
6566    }
6567
6568    #[test]
6569    fn buffer_size_below_h2_minimum_rejected() {
6570        // Default ALPN ["h2", "http/1.1"] + buffer_size = 8192 must error.
6571        let toml_content = r#"
6572            command_socket = "/tmp/sozu_test.sock"
6573            worker_count = 1
6574            buffer_size = 8192
6575
6576            [[listeners]]
6577            protocol = "https"
6578            address = "127.0.0.1:8443"
6579        "#;
6580        let file_config: FileConfig =
6581            toml::from_str(toml_content).expect("Could not parse TOML config");
6582        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
6583        match result {
6584            Err(ConfigError::BufferSizeTooSmallForH2 {
6585                buffer_size: 8192,
6586                minimum: 16_393,
6587                listeners: 1,
6588            }) => {}
6589            other => panic!("expected BufferSizeTooSmallForH2, got {other:?}"),
6590        }
6591    }
6592
6593    #[test]
6594    fn buffer_size_below_h2_minimum_accepted_when_no_h2_listener() {
6595        // Drop "h2" from ALPN — buffer_size = 8192 is now valid.
6596        let toml_content = r#"
6597            command_socket = "/tmp/sozu_test.sock"
6598            worker_count = 1
6599            buffer_size = 8192
6600
6601            [[listeners]]
6602            protocol = "https"
6603            address = "127.0.0.1:8443"
6604            alpn_protocols = ["http/1.1"]
6605        "#;
6606        let file_config: FileConfig =
6607            toml::from_str(toml_content).expect("Could not parse TOML config");
6608        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
6609        assert!(
6610            result.is_ok(),
6611            "non-H2 HTTPS listener with sub-16393 buffer should be accepted: {result:?}"
6612        );
6613    }
6614
6615    fn command_socket_mode_of(mode_line: &str) -> Result<Config, ConfigError> {
6616        let toml_content = format!(
6617            r#"
6618            command_socket = "/tmp/sozu_test.sock"
6619            worker_count = 1
6620            {mode_line}
6621        "#
6622        );
6623        let file_config: FileConfig =
6624            toml::from_str(&toml_content).expect("Could not parse TOML config");
6625        ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config()
6626    }
6627
6628    #[test]
6629    fn command_socket_mode_defaults_to_0600() {
6630        let config = command_socket_mode_of("").expect("config without the key must load");
6631        assert_eq!(config.command_socket_mode, 0o600);
6632        assert_eq!(DEFAULT_COMMAND_SOCKET_MODE, 0o600);
6633    }
6634
6635    #[test]
6636    fn command_socket_mode_accepts_octal_strings() {
6637        for (value, expected) in [
6638            ("0660", 0o660),
6639            ("660", 0o660),
6640            ("0600", 0o600),
6641            ("0666", 0o666),
6642            ("777", 0o777),
6643            ("0777", 0o777),
6644            ("600", 0o600),
6645            ("0640", 0o640),
6646        ] {
6647            let config = command_socket_mode_of(&format!("command_socket_mode = \"{value}\""))
6648                .unwrap_or_else(|e| panic!("{value:?} must load: {e}"));
6649            assert_eq!(config.command_socket_mode, expected, "{value:?}");
6650        }
6651    }
6652
6653    #[test]
6654    fn command_socket_mode_rejects_invalid_values() {
6655        for value in [
6656            "", "8", "0668", "abc", "0o660", "0x1b6", "-660", "+660", " 660", "660 ", "1660",
6657            "2770", "4755", "7777", "01000", "00660", "0", "0000", "0066", "0200", "0400", "0466",
6658        ] {
6659            match command_socket_mode_of(&format!("command_socket_mode = \"{value}\"")) {
6660                Err(ConfigError::InvalidCommandSocketMode {
6661                    value: rejected, ..
6662                }) => {
6663                    assert_eq!(rejected, value)
6664                }
6665                other => panic!("{value:?} must be rejected, got {other:?}"),
6666            }
6667        }
6668    }
6669
6670    #[test]
6671    fn command_socket_mode_rejects_a_toml_integer() {
6672        // `660` as a TOML integer is decimal 660 = 0o1224: refuse the type
6673        // rather than guess which base the operator meant. The refusal is a
6674        // TOML deserialization error, raised before `parse_command_socket_mode`.
6675        let dir = tempfile::tempdir().expect("tempdir");
6676        let path = dir.path().join("config.toml");
6677        std::fs::write(
6678            &path,
6679            "command_socket = \"/tmp/sozu_test.sock\"\ncommand_socket_mode = 660\n",
6680        )
6681        .expect("write config");
6682        match FileConfig::load_from_path(path.to_str().expect("utf-8 path")) {
6683            Err(ConfigError::DeserializeToml(message)) => assert!(
6684                message.contains("invalid type: integer"),
6685                "unexpected message: {message}"
6686            ),
6687            other => panic!("an integer mode must fail as DeserializeToml, got {other:?}"),
6688        }
6689    }
6690
6691    #[test]
6692    fn config_without_command_socket_mode_deserializes_to_0600() {
6693        // An upgrade snapshot written by a main process that predates the key
6694        // carries a `Config` without it.
6695        let mut encoded = serde_json::to_value(Config::default()).expect("serialize Config");
6696        encoded
6697            .as_object_mut()
6698            .expect("Config serializes to an object")
6699            .remove("command_socket_mode")
6700            .expect("Config serializes command_socket_mode");
6701        let decoded: Config = serde_json::from_value(encoded).expect("deserialize Config");
6702        assert_eq!(decoded.command_socket_mode, 0o600);
6703    }
6704
6705    #[test]
6706    fn command_buffer_size_exceeds_max_rejected() {
6707        // command_buffer_size > max_command_buffer_size must be rejected at load
6708        // (sozu-proxy/sozu#1416): otherwise the real command Channel::new starts its
6709        // front/back buffers larger than the ceiling max_buffer_size is supposed to
6710        // enforce.
6711        let toml_content = r#"
6712            command_socket = "/tmp/sozu_test.sock"
6713            worker_count = 1
6714            command_buffer_size = 2000000
6715            max_command_buffer_size = 1000000
6716        "#;
6717        let file_config: FileConfig =
6718            toml::from_str(toml_content).expect("Could not parse TOML config");
6719        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
6720        match result {
6721            Err(ConfigError::CommandBufferSizeExceedsMax {
6722                command_buffer_size: 2_000_000,
6723                max_command_buffer_size: 1_000_000,
6724            }) => {}
6725            other => panic!("expected CommandBufferSizeExceedsMax, got {other:?}"),
6726        }
6727    }
6728
6729    #[test]
6730    fn command_buffer_size_equal_to_max_accepted() {
6731        // The boundary itself (command_buffer_size == max_command_buffer_size) must
6732        // load: `Channel::new` allocates exactly at the ceiling, never above it.
6733        let toml_content = r#"
6734            command_socket = "/tmp/sozu_test.sock"
6735            worker_count = 1
6736            command_buffer_size = 1000000
6737            max_command_buffer_size = 1000000
6738        "#;
6739        let file_config: FileConfig =
6740            toml::from_str(toml_content).expect("Could not parse TOML config");
6741        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
6742        assert!(
6743            result.is_ok(),
6744            "command_buffer_size == max_command_buffer_size should be accepted: {result:?}"
6745        );
6746    }
6747
6748    #[test]
6749    fn buffer_size_at_h2_minimum_accepted() {
6750        let toml_content = r#"
6751            command_socket = "/tmp/sozu_test.sock"
6752            worker_count = 1
6753            buffer_size = 16393
6754
6755            [[listeners]]
6756            protocol = "https"
6757            address = "127.0.0.1:8443"
6758        "#;
6759        let file_config: FileConfig =
6760            toml::from_str(toml_content).expect("Could not parse TOML config");
6761        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
6762        assert!(
6763            result.is_ok(),
6764            "buffer_size at the H2 minimum should be accepted: {result:?}"
6765        );
6766    }
6767
6768    #[test]
6769    fn alpn_protocols_default() {
6770        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
6771        let config = builder.to_tls(None).expect("to_tls should succeed");
6772        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
6773    }
6774
6775    #[test]
6776    fn alpn_protocols_custom() {
6777        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
6778        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned()]));
6779        let config = builder.to_tls(None).expect("to_tls should succeed");
6780        assert_eq!(config.alpn_protocols, vec!["http/1.1"]);
6781    }
6782
6783    #[test]
6784    fn alpn_protocols_invalid_rejected() {
6785        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
6786        builder.with_alpn_protocols(Some(vec!["h3".to_owned()]));
6787        let result = builder.to_tls(None);
6788        assert!(result.is_err());
6789        let err = result.unwrap_err();
6790        assert!(
6791            err.to_string().contains("h3"),
6792            "error should mention the invalid protocol: {err}"
6793        );
6794    }
6795
6796    #[test]
6797    fn alpn_protocols_empty_uses_default() {
6798        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
6799        builder.with_alpn_protocols(Some(vec![]));
6800        let config = builder.to_tls(None).expect("to_tls should succeed");
6801        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
6802    }
6803
6804    #[test]
6805    fn alpn_protocols_deduplicated() {
6806        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
6807        builder.with_alpn_protocols(Some(vec![
6808            "h2".to_owned(),
6809            "h2".to_owned(),
6810            "http/1.1".to_owned(),
6811        ]));
6812        let config = builder.to_tls(None).expect("to_tls should succeed");
6813        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
6814    }
6815
6816    #[test]
6817    fn alpn_protocols_order_preserved() {
6818        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
6819        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned(), "h2".to_owned()]));
6820        let config = builder.to_tls(None).expect("to_tls should succeed");
6821        assert_eq!(config.alpn_protocols, vec!["http/1.1", "h2"]);
6822    }
6823
6824    /// CRLF or NUL in a `[[clusters.<id>.frontends.headers]]` value
6825    /// would let an operator-supplied config splice arbitrary
6826    /// header / request lines into the H1 wire on the backend side
6827    /// (CWE-113). The H2 emission path filters at runtime; we reject
6828    /// at config-load time as a defense in depth.
6829    #[test]
6830    fn parse_header_edit_rejects_crlf_in_value() {
6831        let entry = HeaderEditConfig {
6832            position: "request".to_owned(),
6833            key: "X-Test".to_owned(),
6834            value: "value\r\nEvil-Header: stolen".to_owned(),
6835        };
6836        let err = parse_header_edit(0, &entry).expect_err("CRLF in value must be rejected");
6837        match err {
6838            ConfigError::InvalidHeaderBytes { index, field } => {
6839                assert_eq!(index, 0);
6840                assert_eq!(field, "value");
6841            }
6842            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
6843        }
6844    }
6845
6846    #[test]
6847    fn parse_header_edit_rejects_lf_in_key() {
6848        let entry = HeaderEditConfig {
6849            position: "response".to_owned(),
6850            key: "X-\nTest".to_owned(),
6851            value: "ok".to_owned(),
6852        };
6853        let err = parse_header_edit(2, &entry).expect_err("LF in key must be rejected");
6854        match err {
6855            ConfigError::InvalidHeaderBytes { index, field } => {
6856                assert_eq!(index, 2);
6857                assert_eq!(field, "key");
6858            }
6859            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
6860        }
6861    }
6862
6863    #[test]
6864    fn parse_header_edit_rejects_nul() {
6865        let entry = HeaderEditConfig {
6866            position: "both".to_owned(),
6867            key: "X-Test".to_owned(),
6868            value: "with\0nul".to_owned(),
6869        };
6870        assert!(matches!(
6871            parse_header_edit(0, &entry),
6872            Err(ConfigError::InvalidHeaderBytes { .. })
6873        ));
6874    }
6875
6876    /// Horizontal tab `\t` (0x09) is permitted in field values per
6877    /// RFC 9110 §5.5 (folded-header obs-fold parts). The value-side
6878    /// validator must NOT reject it — otherwise legitimate operator
6879    /// configs (e.g. `Authorization: Basic\tCREDENTIALS`) become
6880    /// unusable. The key-side validator IS stricter (token grammar).
6881    #[test]
6882    fn parse_header_edit_accepts_tab_in_value() {
6883        let entry = HeaderEditConfig {
6884            position: "request".to_owned(),
6885            key: "X-Test".to_owned(),
6886            value: "with\ttab".to_owned(),
6887        };
6888        let header = parse_header_edit(0, &entry).expect("tab in value must be accepted");
6889        assert_eq!(header.val, "with\ttab");
6890    }
6891
6892    /// Header NAMES follow `token` grammar per RFC 9110 §5.1. HTAB and
6893    /// SP are NOT tchar; the key-side validator must reject them even
6894    /// though the value-side validator permits HTAB. Without this,
6895    /// an operator entry like `key = "Host\t"` would emit `Host\t: …`
6896    /// on the H1 wire and produce an invalid (but parser-tolerant)
6897    /// header line that some backends silently accept as `Host:`.
6898    #[test]
6899    fn parse_header_edit_rejects_tab_in_key() {
6900        let entry = HeaderEditConfig {
6901            position: "request".to_owned(),
6902            key: "Host\t".to_owned(),
6903            value: "ok".to_owned(),
6904        };
6905        let err = parse_header_edit(0, &entry).expect_err("HTAB in key must be rejected");
6906        match err {
6907            ConfigError::InvalidHeaderBytes { field, .. } => assert_eq!(field, "key"),
6908            other => panic!("expected InvalidHeaderBytes{{field=\"key\"}}, got {other:?}"),
6909        }
6910    }
6911
6912    #[test]
6913    fn parse_header_edit_rejects_space_in_key() {
6914        let entry = HeaderEditConfig {
6915            position: "request".to_owned(),
6916            key: "X Test".to_owned(),
6917            value: "ok".to_owned(),
6918        };
6919        let err = parse_header_edit(0, &entry).expect_err("SP in key must be rejected");
6920        assert!(matches!(err, ConfigError::InvalidHeaderBytes { .. }));
6921    }
6922
6923    #[test]
6924    fn parse_header_edit_rejects_empty_key() {
6925        let entry = HeaderEditConfig {
6926            position: "request".to_owned(),
6927            key: String::new(),
6928            value: "ok".to_owned(),
6929        };
6930        let err = parse_header_edit(0, &entry).expect_err("empty key must be rejected");
6931        assert!(matches!(
6932            err,
6933            ConfigError::InvalidHeaderBytes { field: "key", .. }
6934        ));
6935    }
6936
6937    #[test]
6938    fn parse_header_edit_accepts_clean_value() {
6939        let entry = HeaderEditConfig {
6940            position: "request".to_owned(),
6941            key: "X-Tenant".to_owned(),
6942            value: "alpha".to_owned(),
6943        };
6944        let header = parse_header_edit(0, &entry).expect("clean value must be accepted");
6945        assert_eq!(header.key, "X-Tenant");
6946        assert_eq!(header.val, "alpha");
6947    }
6948
6949    /// A bare string with no scheme prefix is the inline literal body.
6950    /// This is the common case — short canned responses inline in TOML
6951    /// or a `--answer` flag, no disk I/O.
6952    #[test]
6953    fn resolve_answer_source_bare_string_is_literal() {
6954        let body = resolve_answer_source("HTTP/1.1 503 Service Unavailable\r\n\r\nbusy")
6955            .expect("bare-string source must resolve");
6956        assert_eq!(body, "HTTP/1.1 503 Service Unavailable\r\n\r\nbusy");
6957    }
6958
6959    #[test]
6960    fn resolve_answer_source_empty_string_is_legitimate() {
6961        let body = resolve_answer_source("").expect("empty source must resolve");
6962        assert_eq!(body, "");
6963    }
6964
6965    /// `file://` opts into reading the path off disk. A non-existent
6966    /// path bubbles up as `ConfigError::FileOpen` so the operator gets
6967    /// the same diagnostics as the existing per-status `answer_NNN`
6968    /// flow.
6969    #[test]
6970    fn resolve_answer_source_file_scheme_missing_file_errors() {
6971        let err = resolve_answer_source("file:///nonexistent/sozu-test/never.http")
6972            .expect_err("missing path must error");
6973        assert!(matches!(err, ConfigError::FileOpen { .. }));
6974    }
6975
6976    /// `file://` strips the scheme; an empty path after the scheme is
6977    /// rejected (empty path on filesystem read).
6978    #[test]
6979    fn resolve_answer_source_file_scheme_empty_path_errors() {
6980        let err = resolve_answer_source("file://").expect_err("empty path must error");
6981        assert!(matches!(err, ConfigError::FileOpen { .. }));
6982    }
6983
6984    // ── TCP SNI/ALPN frontends (sozu-proxy/sozu#1279) ───────────────────────
6985
6986    /// A legacy-style TCP frontend TOML (no `hostname`/`alpn` keys at all)
6987    /// must still parse, and the built frontend/listener must carry the
6988    /// same values as before this feature existed: `sni = None`,
6989    /// `alpn = []`, and the proto default SNI-preread knobs (unused since
6990    /// no SNI frontend targets the listener).
6991    #[test]
6992    fn legacy_tcp_frontend_toml_without_sni_alpn_parses_unchanged() {
6993        let toml_content = r#"
6994            command_socket = "/tmp/sozu_test.sock"
6995            worker_count = 1
6996
6997            [[listeners]]
6998            protocol = "tcp"
6999            address  = "127.0.0.1:9000"
7000
7001            [clusters.legacy]
7002            protocol       = "tcp"
7003            load_balancing = "ROUND_ROBIN"
7004            frontends = [
7005              { address = "127.0.0.1:9000" }
7006            ]
7007            backends = [
7008              { address = "10.0.0.1:9000" }
7009            ]
7010        "#;
7011        let file_config: FileConfig =
7012            toml::from_str(toml_content).expect("Could not parse legacy TCP TOML");
7013        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
7014            .into_config()
7015            .expect("legacy TCP config without sni/alpn must load unchanged");
7016
7017        assert_eq!(config.tcp_listeners.len(), 1);
7018        let listener = &config.tcp_listeners[0];
7019        assert_eq!(
7020            listener.sni_preread_timeout,
7021            Some(DEFAULT_SNI_PREREAD_TIMEOUT),
7022            "proto default sni_preread_timeout must be populated even though unused"
7023        );
7024        assert_eq!(
7025            listener.sni_preread_max_bytes,
7026            Some(DEFAULT_SNI_PREREAD_MAX_BYTES),
7027            "proto default sni_preread_max_bytes must be populated even though unused"
7028        );
7029
7030        let messages = config
7031            .generate_config_messages()
7032            .expect("Could not generate config messages");
7033        let tcp_frontend = messages
7034            .iter()
7035            .find_map(|m| match &m.content.request_type {
7036                Some(RequestType::AddTcpFrontend(f)) => Some(f),
7037                _ => None,
7038            })
7039            .expect("AddTcpFrontend must be present");
7040        assert_eq!(tcp_frontend.sni, None, "legacy frontend must carry no sni");
7041        assert!(
7042            tcp_frontend.alpn.is_empty(),
7043            "legacy frontend must carry no alpn"
7044        );
7045    }
7046
7047    /// sozu#633: the catch-all frontend `doc/configure.md` documents —
7048    /// `hostname = "*"` with `position = "POST"` — loads from TOML and
7049    /// reaches the worker as a `Post` rule on `*`. The position is an enum
7050    /// spelled in upper case: `"Post"` is refused with the serde message the
7051    /// documentation quotes, rather than silently falling back to `TREE`.
7052    ///
7053    /// To SEE THIS RED: in `command/build.rs`, drop the
7054    /// `#[serde(rename_all = "SCREAMING_SNAKE_CASE")]` enum attribute. The
7055    /// first `toml::from_str` then fails with
7056    /// "unknown variant `POST`, expected one of `Pre`, `Post`, `Tree`".
7057    #[test]
7058    fn a_catch_all_post_frontend_loads_from_toml_and_rejects_a_mixed_case_position() {
7059        let catch_all = |position: &str| {
7060            format!(
7061                r#"
7062            command_socket = "/tmp/sozu_test.sock"
7063            worker_count = 1
7064
7065            [[listeners]]
7066            protocol = "http"
7067            address  = "127.0.0.1:8080"
7068
7069            [clusters.fallback]
7070            protocol = "http"
7071            frontends = [
7072              {{ address = "127.0.0.1:8080", hostname = "*", position = "{position}" }}
7073            ]
7074            backends = [
7075              {{ address = "10.0.0.1:8000" }}
7076            ]
7077        "#
7078            )
7079        };
7080
7081        let file_config: FileConfig = toml::from_str(&catch_all("POST"))
7082            .expect("an upper-case `position = \"POST\"` must parse");
7083        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
7084            .into_config()
7085            .expect("a catch-all POST frontend must load");
7086        let messages = config
7087            .generate_config_messages()
7088            .expect("Could not generate config messages");
7089        let frontend = messages
7090            .iter()
7091            .find_map(|m| match &m.content.request_type {
7092                Some(RequestType::AddHttpFrontend(f)) => Some(f),
7093                _ => None,
7094            })
7095            .expect("AddHttpFrontend must be present");
7096        assert_eq!(frontend.hostname, "*");
7097        assert_eq!(frontend.position, RulePosition::Post as i32);
7098
7099        let error = toml::from_str::<FileConfig>(&catch_all("Post"))
7100            .expect_err("a mixed-case `position = \"Post\"` must be refused")
7101            .to_string();
7102        assert!(
7103            error.contains("unknown variant `Post`, expected one of `PRE`, `POST`, `TREE`"),
7104            "unexpected error: {error}",
7105        );
7106    }
7107
7108    /// sozu#322: `forwarded_headers` is a per-listener enum spelled
7109    /// `both`, `x_forwarded`, `rfc7239` or `none`. Each value reaches the
7110    /// wire config of an HTTP and an HTTPS listener, an absent key is
7111    /// `both` (the historical behaviour), and any other spelling is refused
7112    /// by serde instead of silently falling back to the default.
7113    #[test]
7114    fn forwarded_headers_parses_every_mode_defaults_to_both_and_rejects_others() {
7115        let listeners = |value: Option<&str>| {
7116            let line = value
7117                .map(|value| format!("forwarded_headers = \"{value}\""))
7118                .unwrap_or_default();
7119            format!(
7120                r#"
7121            command_socket = "/tmp/sozu_test.sock"
7122            worker_count = 1
7123
7124            [[listeners]]
7125            protocol = "http"
7126            address  = "127.0.0.1:8080"
7127            {line}
7128
7129            [[listeners]]
7130            protocol = "https"
7131            address  = "127.0.0.1:8443"
7132            {line}
7133            "#
7134            )
7135        };
7136        let load = |value: Option<&str>| {
7137            let file_config: FileConfig =
7138                toml::from_str(&listeners(value)).expect("the listeners must parse");
7139            let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
7140                .into_config()
7141                .expect("the listeners must load");
7142            (
7143                config.http_listeners[0].forwarded_headers,
7144                config.https_listeners[0].forwarded_headers,
7145            )
7146        };
7147
7148        for (spelling, expected) in [
7149            (None, ForwardedHeaders::Both),
7150            (Some("both"), ForwardedHeaders::Both),
7151            (Some("x_forwarded"), ForwardedHeaders::XForwarded),
7152            (Some("rfc7239"), ForwardedHeaders::Rfc7239),
7153            (Some("none"), ForwardedHeaders::None),
7154        ] {
7155            let expected = Some(expected as i32);
7156            assert_eq!(load(spelling), (expected, expected), "{spelling:?}");
7157        }
7158
7159        for invalid in ["RFC7239", "x-forwarded", "forwarded", ""] {
7160            let error = toml::from_str::<FileConfig>(&listeners(Some(invalid)))
7161                .expect_err("an unknown forwarded_headers value must be refused")
7162                .to_string();
7163            assert!(
7164                error.contains(&format!(
7165                    "unknown variant `{invalid}`, expected one of `both`, `x_forwarded`, `rfc7239`, `none`"
7166                )),
7167                "{invalid:?}: unexpected error: {error}",
7168            );
7169        }
7170    }
7171
7172    /// `hostname` on a TCP frontend maps to the wire `sni` field, exact
7173    /// hostnames and a single leading `*.` wildcard are both accepted.
7174    #[test]
7175    fn tcp_frontend_hostname_maps_to_sni_exact_and_wildcard() {
7176        let toml_content = r#"
7177            command_socket = "/tmp/sozu_test.sock"
7178            worker_count = 1
7179
7180            [[listeners]]
7181            protocol = "tcp"
7182            address  = "127.0.0.1:9010"
7183
7184            [clusters.exact]
7185            protocol       = "tcp"
7186            load_balancing = "ROUND_ROBIN"
7187            frontends = [
7188              { address = "127.0.0.1:9010", hostname = "example.com", alpn = ["h2"] }
7189            ]
7190            backends = [ { address = "10.0.0.1:9010" } ]
7191
7192            [clusters.wildcard]
7193            protocol       = "tcp"
7194            load_balancing = "ROUND_ROBIN"
7195            frontends = [
7196              { address = "127.0.0.1:9010", hostname = "*.example.com" }
7197            ]
7198            backends = [ { address = "10.0.0.2:9010" } ]
7199        "#;
7200        let file_config: FileConfig =
7201            toml::from_str(toml_content).expect("Could not parse TOML config");
7202        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
7203            .into_config()
7204            .expect("exact + wildcard SNI frontends on distinct sni must load");
7205
7206        let messages = config
7207            .generate_config_messages()
7208            .expect("Could not generate config messages");
7209        let mut frontends: Vec<_> = messages
7210            .iter()
7211            .filter_map(|m| match &m.content.request_type {
7212                Some(RequestType::AddTcpFrontend(f)) => Some(f.clone()),
7213                _ => None,
7214            })
7215            .collect();
7216        frontends.sort_by(|a, b| a.cluster_id.cmp(&b.cluster_id));
7217
7218        assert_eq!(frontends.len(), 2);
7219        assert_eq!(frontends[0].sni, Some("example.com".to_string()));
7220        assert_eq!(frontends[0].alpn, vec!["h2".to_string()]);
7221        assert_eq!(frontends[1].sni, Some("*.example.com".to_string()));
7222        assert!(frontends[1].alpn.is_empty());
7223    }
7224
7225    /// (a) An SNI pattern with more than one wildcard label, an embedded
7226    /// `*`, an empty label, or any `/` (a leftmost `/.../` label would be
7227    /// inserted into the `pattern_trie` route table as a REGEX segment,
7228    /// silently widening routing) is rejected at config-load.
7229    #[test]
7230    fn tcp_frontend_invalid_sni_pattern_rejected() {
7231        for invalid in [
7232            "*.*.example.com",
7233            "foo.*.com",
7234            "*",
7235            "example..com",
7236            "",
7237            "/[a-z]+/.example.com",
7238            "foo/bar.example.com",
7239        ] {
7240            let frontend = FileClusterFrontendConfig {
7241                address: "127.0.0.1:8080".parse().unwrap(),
7242                hostname: Some(invalid.to_string()),
7243                alpn: vec![],
7244                path: None,
7245                path_type: None,
7246                method: None,
7247                certificate: None,
7248                key: None,
7249                certificate_chain: None,
7250                tls_versions: vec![],
7251                position: RulePosition::Tree,
7252                tags: None,
7253                redirect: None,
7254                redirect_scheme: None,
7255                redirect_template: None,
7256                rewrite_host: None,
7257                rewrite_path: None,
7258                rewrite_port: None,
7259                required_auth: None,
7260                headers: None,
7261                hsts: None,
7262            };
7263            match frontend.to_tcp_front() {
7264                Err(ConfigError::InvalidSniPattern { sni }) => assert_eq!(sni, invalid),
7265                other => panic!("expected InvalidSniPattern for {invalid:?}, got {other:?}"),
7266            }
7267        }
7268    }
7269
7270    /// (a) A non-ASCII SNI pattern is rejected loudly: on-wire SNI is
7271    /// always an ASCII A-label (RFC 6066 / IDNA), so a Unicode U-label in
7272    /// the config would load fine but never match a ClientHello — a
7273    /// silent routing failure. The error names the punycode form the
7274    /// operator must write instead.
7275    #[test]
7276    fn tcp_frontend_non_ascii_sni_pattern_rejected() {
7277        for non_ascii in ["münchen.example", "*.bücher.example", "日本.example"] {
7278            let frontend = FileClusterFrontendConfig {
7279                address: "127.0.0.1:8080".parse().unwrap(),
7280                hostname: Some(non_ascii.to_string()),
7281                alpn: vec![],
7282                path: None,
7283                path_type: None,
7284                method: None,
7285                certificate: None,
7286                key: None,
7287                certificate_chain: None,
7288                tls_versions: vec![],
7289                position: RulePosition::Tree,
7290                tags: None,
7291                redirect: None,
7292                redirect_scheme: None,
7293                redirect_template: None,
7294                rewrite_host: None,
7295                rewrite_path: None,
7296                rewrite_port: None,
7297                required_auth: None,
7298                headers: None,
7299                hsts: None,
7300            };
7301            match frontend.to_tcp_front() {
7302                Err(ConfigError::NonAsciiSniPattern { sni }) => assert_eq!(sni, non_ascii),
7303                other => panic!("expected NonAsciiSniPattern for {non_ascii:?}, got {other:?}"),
7304            }
7305        }
7306    }
7307
7308    /// The punycode A-label form of an internationalized hostname — what
7309    /// the NonAsciiSniPattern error tells the operator to write — is
7310    /// accepted, both exact and wildcarded, and case-normalized like any
7311    /// other ASCII pattern.
7312    #[test]
7313    fn tcp_frontend_punycode_sni_pattern_accepted() {
7314        assert_eq!(
7315            validate_sni_pattern("xn--mnchen-3ya.example").expect("A-label must be accepted"),
7316            "xn--mnchen-3ya.example"
7317        );
7318        assert_eq!(
7319            validate_sni_pattern("*.xn--bcher-kva.example")
7320                .expect("wildcarded A-label must be accepted"),
7321            "*.xn--bcher-kva.example"
7322        );
7323        assert_eq!(
7324            validate_sni_pattern("XN--MNCHEN-3YA.Example")
7325                .expect("mixed-case A-label must be accepted"),
7326            "xn--mnchen-3ya.example",
7327            "A-label patterns are ASCII-lowercased like any other pattern"
7328        );
7329    }
7330
7331    /// `alpn` is a TCP-only concept; setting it on an HTTP frontend is
7332    /// rejected rather than silently ignored.
7333    #[test]
7334    fn alpn_rejected_on_http_frontend() {
7335        let frontend = FileClusterFrontendConfig {
7336            address: "127.0.0.1:8080".parse().unwrap(),
7337            hostname: Some("example.com".to_owned()),
7338            alpn: vec!["h2".to_string()],
7339            path: None,
7340            path_type: None,
7341            method: None,
7342            certificate: None,
7343            key: None,
7344            certificate_chain: None,
7345            tls_versions: vec![],
7346            position: RulePosition::Tree,
7347            tags: None,
7348            redirect: None,
7349            redirect_scheme: None,
7350            redirect_template: None,
7351            rewrite_host: None,
7352            rewrite_path: None,
7353            rewrite_port: None,
7354            required_auth: None,
7355            headers: None,
7356            hsts: None,
7357        };
7358        match frontend.to_http_front("api") {
7359            Err(ConfigError::InvalidFrontendConfig(field)) => assert_eq!(field, "alpn"),
7360            other => panic!("expected InvalidFrontendConfig(\"alpn\"), got {other:?}"),
7361        }
7362    }
7363
7364    /// A TCP frontend that sets `alpn` but leaves `hostname` (the wire
7365    /// `sni` field) unset is a config error: an ALPN matcher only ever gets
7366    /// consulted from within the SNI-scoped preread route table, so a
7367    /// no-SNI frontend would install the raw catch-all path and silently
7368    /// never enforce the configured protocol list.
7369    #[test]
7370    fn tcp_frontend_alpn_without_sni_rejected() {
7371        let toml_content = r#"
7372            command_socket = "/tmp/sozu_test.sock"
7373            worker_count = 1
7374
7375            [[listeners]]
7376            protocol = "tcp"
7377            address  = "127.0.0.1:9019"
7378
7379            [clusters.a]
7380            protocol       = "tcp"
7381            load_balancing = "ROUND_ROBIN"
7382            frontends = [
7383              { address = "127.0.0.1:9019", alpn = ["h2"] }
7384            ]
7385            backends = [ { address = "10.0.0.1:9019" } ]
7386        "#;
7387        let file_config: FileConfig =
7388            toml::from_str(toml_content).expect("Could not parse TOML config");
7389        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7390        match result {
7391            Err(ConfigError::AlpnWithoutSni { address }) => {
7392                assert_eq!(address.to_string(), "127.0.0.1:9019");
7393            }
7394            other => panic!("expected AlpnWithoutSni, got {other:?}"),
7395        }
7396    }
7397
7398    /// (b) Two TCP frontends on the same (address, sni) advertising an
7399    /// overlapping ALPN protocol is a config error — routing on that
7400    /// listener would otherwise depend on iteration order.
7401    #[test]
7402    fn tcp_frontend_alpn_overlap_rejected() {
7403        let toml_content = r#"
7404            command_socket = "/tmp/sozu_test.sock"
7405            worker_count = 1
7406
7407            [[listeners]]
7408            protocol = "tcp"
7409            address  = "127.0.0.1:9020"
7410
7411            [clusters.a]
7412            protocol       = "tcp"
7413            load_balancing = "ROUND_ROBIN"
7414            frontends = [
7415              { address = "127.0.0.1:9020", hostname = "example.com", alpn = ["h2"] }
7416            ]
7417            backends = [ { address = "10.0.0.1:9020" } ]
7418
7419            [clusters.b]
7420            protocol       = "tcp"
7421            load_balancing = "ROUND_ROBIN"
7422            frontends = [
7423              { address = "127.0.0.1:9020", hostname = "example.com", alpn = ["h2", "http/1.1"] }
7424            ]
7425            backends = [ { address = "10.0.0.2:9020" } ]
7426        "#;
7427        let file_config: FileConfig =
7428            toml::from_str(toml_content).expect("Could not parse TOML config");
7429        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7430        match result {
7431            Err(ConfigError::TcpFrontendAlpnOverlap { protocol, .. }) => {
7432                assert_eq!(protocol, "h2");
7433            }
7434            other => panic!("expected TcpFrontendAlpnOverlap, got {other:?}"),
7435        }
7436    }
7437
7438    /// (b) At most one TCP frontend per (address, sni) may leave `alpn`
7439    /// empty (the catch-all match); a second is as ambiguous as an
7440    /// overlapping explicit protocol.
7441    #[test]
7442    fn tcp_frontend_multiple_alpn_catch_all_rejected() {
7443        let toml_content = r#"
7444            command_socket = "/tmp/sozu_test.sock"
7445            worker_count = 1
7446
7447            [[listeners]]
7448            protocol = "tcp"
7449            address  = "127.0.0.1:9021"
7450
7451            [clusters.a]
7452            protocol       = "tcp"
7453            load_balancing = "ROUND_ROBIN"
7454            frontends = [
7455              { address = "127.0.0.1:9021", hostname = "example.com" }
7456            ]
7457            backends = [ { address = "10.0.0.1:9021" } ]
7458
7459            [clusters.b]
7460            protocol       = "tcp"
7461            load_balancing = "ROUND_ROBIN"
7462            frontends = [
7463              { address = "127.0.0.1:9021", hostname = "example.com" }
7464            ]
7465            backends = [ { address = "10.0.0.2:9021" } ]
7466        "#;
7467        let file_config: FileConfig =
7468            toml::from_str(toml_content).expect("Could not parse TOML config");
7469        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7470        assert!(
7471            matches!(
7472                result,
7473                Err(ConfigError::TcpFrontendMultipleAlpnCatchAll { .. })
7474            ),
7475            "expected TcpFrontendMultipleAlpnCatchAll, got {result:?}"
7476        );
7477    }
7478
7479    /// (c) A listener targeted by both a no-SNI frontend and an SNI-scoped
7480    /// frontend is a config error: an SNI-enabled listener must not also
7481    /// carry a raw-TCP fallback.
7482    #[test]
7483    fn tcp_listener_mixes_sni_and_no_sni_rejected() {
7484        let toml_content = r#"
7485            command_socket = "/tmp/sozu_test.sock"
7486            worker_count = 1
7487
7488            [[listeners]]
7489            protocol = "tcp"
7490            address  = "127.0.0.1:9022"
7491
7492            [clusters.a]
7493            protocol       = "tcp"
7494            load_balancing = "ROUND_ROBIN"
7495            frontends = [
7496              { address = "127.0.0.1:9022", hostname = "example.com" }
7497            ]
7498            backends = [ { address = "10.0.0.1:9022" } ]
7499
7500            [clusters.b]
7501            protocol       = "tcp"
7502            load_balancing = "ROUND_ROBIN"
7503            frontends = [
7504              { address = "127.0.0.1:9022" }
7505            ]
7506            backends = [ { address = "10.0.0.2:9022" } ]
7507        "#;
7508        let file_config: FileConfig =
7509            toml::from_str(toml_content).expect("Could not parse TOML config");
7510        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7511        assert!(
7512            matches!(result, Err(ConfigError::TcpListenerMixesSniAndNoSni { .. })),
7513            "expected TcpListenerMixesSniAndNoSni, got {result:?}"
7514        );
7515    }
7516
7517    /// (d) `sni_preread_timeout` (proto default: 5s) exceeding the
7518    /// listener's `front_timeout` is rejected — but only when an SNI
7519    /// frontend actually targets that listener, so legacy TCP-only
7520    /// configs with a low front_timeout keep loading unchanged.
7521    #[test]
7522    fn sni_preread_timeout_exceeding_front_timeout_rejected() {
7523        let toml_content = r#"
7524            command_socket = "/tmp/sozu_test.sock"
7525            worker_count = 1
7526
7527            [[listeners]]
7528            protocol      = "tcp"
7529            address       = "127.0.0.1:9030"
7530            front_timeout = 2
7531
7532            [clusters.a]
7533            protocol       = "tcp"
7534            load_balancing = "ROUND_ROBIN"
7535            frontends = [
7536              { address = "127.0.0.1:9030", hostname = "example.com" }
7537            ]
7538            backends = [ { address = "10.0.0.1:9030" } ]
7539        "#;
7540        let file_config: FileConfig =
7541            toml::from_str(toml_content).expect("Could not parse TOML config");
7542        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7543        match result {
7544            Err(ConfigError::SniPrereadTimeoutExceedsFrontTimeout {
7545                sni_preread_timeout: 5,
7546                front_timeout: 2,
7547                ..
7548            }) => {}
7549            other => panic!("expected SniPrereadTimeoutExceedsFrontTimeout, got {other:?}"),
7550        }
7551    }
7552
7553    /// (e) `sni_preread_max_bytes` (proto default: 16384) exceeding the
7554    /// global `buffer_size` is rejected — again only when an SNI frontend
7555    /// targets the listener.
7556    #[test]
7557    fn sni_preread_max_bytes_exceeding_buffer_size_rejected() {
7558        let toml_content = r#"
7559            command_socket = "/tmp/sozu_test.sock"
7560            worker_count = 1
7561            buffer_size = 8192
7562
7563            [[listeners]]
7564            protocol = "tcp"
7565            address  = "127.0.0.1:9031"
7566
7567            [clusters.a]
7568            protocol       = "tcp"
7569            load_balancing = "ROUND_ROBIN"
7570            frontends = [
7571              { address = "127.0.0.1:9031", hostname = "example.com" }
7572            ]
7573            backends = [ { address = "10.0.0.1:9031" } ]
7574        "#;
7575        let file_config: FileConfig =
7576            toml::from_str(toml_content).expect("Could not parse TOML config");
7577        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7578        match result {
7579            Err(ConfigError::SniPrereadMaxBytesExceedsBufferSize {
7580                sni_preread_max_bytes: 16384,
7581                buffer_size: 8192,
7582                ..
7583            }) => {}
7584            other => panic!("expected SniPrereadMaxBytesExceedsBufferSize, got {other:?}"),
7585        }
7586    }
7587
7588    /// `sni_preread_max_bytes = 0` on an SNI-enabled listener is rejected:
7589    /// the preread shell would issue zero-length reads that never make
7590    /// progress, spinning until the event-loop iteration guard trips
7591    /// instead of ever reaching a routing decision.
7592    #[test]
7593    fn sni_preread_max_bytes_zero_rejected() {
7594        let toml_content = r#"
7595            command_socket = "/tmp/sozu_test.sock"
7596            worker_count = 1
7597
7598            [[listeners]]
7599            protocol             = "tcp"
7600            address              = "127.0.0.1:9033"
7601            sni_preread_max_bytes = 0
7602
7603            [clusters.a]
7604            protocol       = "tcp"
7605            load_balancing = "ROUND_ROBIN"
7606            frontends = [
7607              { address = "127.0.0.1:9033", hostname = "example.com" }
7608            ]
7609            backends = [ { address = "10.0.0.1:9033" } ]
7610        "#;
7611        let file_config: FileConfig =
7612            toml::from_str(toml_content).expect("Could not parse TOML config");
7613        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7614        match result {
7615            Err(ConfigError::SniPrereadMaxBytesTooSmall {
7616                sni_preread_max_bytes: 0,
7617                minimum: 5,
7618                ..
7619            }) => {}
7620            other => panic!("expected SniPrereadMaxBytesTooSmall, got {other:?}"),
7621        }
7622    }
7623
7624    /// The floor itself (`MIN_SNI_PREREAD_MAX_BYTES` = 5 bytes) must load
7625    /// successfully — only values strictly below it are rejected.
7626    #[test]
7627    fn sni_preread_max_bytes_at_the_floor_loads() {
7628        let toml_content = r#"
7629            command_socket = "/tmp/sozu_test.sock"
7630            worker_count = 1
7631
7632            [[listeners]]
7633            protocol             = "tcp"
7634            address              = "127.0.0.1:9034"
7635            sni_preread_max_bytes = 5
7636
7637            [clusters.a]
7638            protocol       = "tcp"
7639            load_balancing = "ROUND_ROBIN"
7640            frontends = [
7641              { address = "127.0.0.1:9034", hostname = "example.com" }
7642            ]
7643            backends = [ { address = "10.0.0.1:9034" } ]
7644        "#;
7645        let file_config: FileConfig =
7646            toml::from_str(toml_content).expect("Could not parse TOML config");
7647        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7648        assert!(
7649            result.is_ok(),
7650            "sni_preread_max_bytes at the exact floor must load: {result:?}"
7651        );
7652    }
7653
7654    /// Gating proof for (d)/(e): a TCP listener with a low front_timeout
7655    /// and small buffer_size, but *no* SNI frontend, must load unchanged —
7656    /// the new knobs are dead weight on a listener that never prereads.
7657    #[test]
7658    fn sni_preread_validation_ignored_without_sni_frontend() {
7659        let toml_content = r#"
7660            command_socket = "/tmp/sozu_test.sock"
7661            worker_count = 1
7662            buffer_size = 8192
7663
7664            [[listeners]]
7665            protocol      = "tcp"
7666            address       = "127.0.0.1:9032"
7667            front_timeout = 2
7668
7669            [clusters.a]
7670            protocol       = "tcp"
7671            load_balancing = "ROUND_ROBIN"
7672            frontends = [
7673              { address = "127.0.0.1:9032" }
7674            ]
7675            backends = [ { address = "10.0.0.1:9032" } ]
7676        "#;
7677        let file_config: FileConfig =
7678            toml::from_str(toml_content).expect("Could not parse TOML config");
7679        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7680        assert!(
7681            result.is_ok(),
7682            "a no-SNI TCP listener must ignore sni_preread validation entirely: {result:?}"
7683        );
7684    }
7685
7686    /// The actual use case sozu-proxy/sozu#1279 exists for: two TCP
7687    /// frontends sharing the same `(address, sni)` with disjoint,
7688    /// non-empty `alpn` lists must load successfully and both must be
7689    /// individually reachable — ALPN multiplexing within one SNI. The
7690    /// negative tests above only prove overlap is rejected; this proves
7691    /// non-overlap actually works.
7692    #[test]
7693    fn tcp_frontend_disjoint_alpn_same_sni_both_load() {
7694        let toml_content = r#"
7695            command_socket = "/tmp/sozu_test.sock"
7696            worker_count = 1
7697
7698            [[listeners]]
7699            protocol = "tcp"
7700            address  = "127.0.0.1:9040"
7701
7702            [clusters.h2_cluster]
7703            protocol       = "tcp"
7704            load_balancing = "ROUND_ROBIN"
7705            frontends = [
7706              { address = "127.0.0.1:9040", hostname = "example.com", alpn = ["h2"] }
7707            ]
7708            backends = [ { address = "10.0.0.1:9040" } ]
7709
7710            [clusters.http11_cluster]
7711            protocol       = "tcp"
7712            load_balancing = "ROUND_ROBIN"
7713            frontends = [
7714              { address = "127.0.0.1:9040", hostname = "example.com", alpn = ["http/1.1"] }
7715            ]
7716            backends = [ { address = "10.0.0.2:9040" } ]
7717        "#;
7718        let file_config: FileConfig =
7719            toml::from_str(toml_content).expect("Could not parse TOML config");
7720        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
7721            .into_config()
7722            .expect("disjoint non-empty ALPN lists on the same (address, sni) must load");
7723
7724        let messages = config
7725            .generate_config_messages()
7726            .expect("Could not generate config messages");
7727        let mut frontends: Vec<_> = messages
7728            .iter()
7729            .filter_map(|m| match &m.content.request_type {
7730                Some(RequestType::AddTcpFrontend(f)) => Some(f.clone()),
7731                _ => None,
7732            })
7733            .collect();
7734        frontends.sort_by(|a, b| a.cluster_id.cmp(&b.cluster_id));
7735
7736        assert_eq!(frontends.len(), 2, "both frontends must be emitted");
7737        assert_eq!(frontends[0].cluster_id, "h2_cluster");
7738        assert_eq!(frontends[0].sni, Some("example.com".to_string()));
7739        assert_eq!(frontends[0].alpn, vec!["h2".to_string()]);
7740        assert_eq!(frontends[1].cluster_id, "http11_cluster");
7741        assert_eq!(frontends[1].sni, Some("example.com".to_string()));
7742        assert_eq!(frontends[1].alpn, vec!["http/1.1".to_string()]);
7743    }
7744
7745    /// A `protocol = "tcp"` cluster frontend resolved against a
7746    /// `protocol = "udp"` listener (`TcpFrontendConfig.udp = true`, set in
7747    /// `populate_clusters`) emits `AddUdpFrontend`, not `AddTcpFrontend` —
7748    /// it carries no `sni`/`alpn` on the wire and must not participate in
7749    /// the TCP-only mixing-ban / ALPN-overlap invariants. Two "tcp"
7750    /// clusters at the same UDP-listener address, one with `hostname` set
7751    /// and one without, must build and emit their messages rather than
7752    /// spuriously tripping `TcpListenerMixesSniAndNoSni`.
7753    ///
7754    /// Message GENERATION is all this asserts. A UDP address is claimed by
7755    /// exactly one frontend (`ConfigState::add_udp_frontend`), so at apply
7756    /// time `load_static_config` admits the first of these two and skips the
7757    /// second with `Skipping a config entry the state refused`. That is the
7758    /// config-file face of the same collision, and it is deliberately not
7759    /// asserted here: this test owns the SNI invariants, not admission.
7760    #[test]
7761    fn udp_routed_tcp_frontends_are_excluded_from_sni_invariants() {
7762        let toml_content = r#"
7763            command_socket = "/tmp/sozu_test.sock"
7764            worker_count = 1
7765
7766            [[listeners]]
7767            protocol = "udp"
7768            address  = "127.0.0.1:9050"
7769
7770            [clusters.a]
7771            protocol       = "tcp"
7772            load_balancing = "ROUND_ROBIN"
7773            frontends = [
7774              { address = "127.0.0.1:9050", hostname = "example.com" }
7775            ]
7776            backends = [ { address = "10.0.0.1:9050" } ]
7777
7778            [clusters.b]
7779            protocol       = "tcp"
7780            load_balancing = "ROUND_ROBIN"
7781            frontends = [
7782              { address = "127.0.0.1:9050" }
7783            ]
7784            backends = [ { address = "10.0.0.2:9050" } ]
7785        "#;
7786        let file_config: FileConfig =
7787            toml::from_str(toml_content).expect("Could not parse TOML config");
7788        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
7789        assert!(
7790            result.is_ok(),
7791            "UDP-routed tcp-cluster frontends must not trip the SNI mixing-ban: {result:?}"
7792        );
7793
7794        let config = result.expect("checked is_ok above");
7795        let messages = config
7796            .generate_config_messages()
7797            .expect("Could not generate config messages");
7798        let add_udp_frontend_count = messages
7799            .iter()
7800            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpFrontend(_))))
7801            .count();
7802        assert_eq!(
7803            add_udp_frontend_count, 2,
7804            "both cluster frontends on the udp listener must emit AddUdpFrontend"
7805        );
7806    }
7807
7808    /// The three subnet keys must travel from TOML through `Config`,
7809    /// and the per-cluster override with them. Nothing else asserts this
7810    /// plumbing: a swapped or dropped key would only surface as a cap
7811    /// that silently never fires.
7812    #[test]
7813    fn subnet_connection_limit_keys_round_trip_from_toml() {
7814        let toml_content = r#"
7815            command_socket   = "/tmp/sozu.sock"
7816            saved_state      = "./state.json"
7817            worker_count     = 1
7818
7819            max_connections_per_ip     = 10
7820            max_connections_per_subnet = 100
7821            subnet_ipv4_prefix         = 24
7822            subnet_ipv6_prefix         = 56
7823
7824            [[listeners]]
7825            protocol = "http"
7826            address  = "127.0.0.1:8080"
7827
7828            [clusters.api]
7829            protocol       = "http"
7830            load_balancing = "ROUND_ROBIN"
7831            max_connections_per_subnet = 7
7832            frontends = [
7833              { address = "127.0.0.1:8080", hostname = "example.com" }
7834            ]
7835            backends = [ { address = "10.0.0.1:8080" } ]
7836        "#;
7837        let file_config: FileConfig =
7838            toml::from_str(toml_content).expect("Could not parse TOML config");
7839        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
7840            .into_config()
7841            .expect("a config with valid subnet prefixes must load");
7842
7843        assert_eq!(
7844            config.max_connections_per_subnet, 100,
7845            "the global subnet cap must survive the TOML round trip"
7846        );
7847        assert_eq!(
7848            config.subnet_ipv4_prefix, 24,
7849            "subnet_ipv4_prefix must survive the TOML round trip"
7850        );
7851        assert_eq!(
7852            config.subnet_ipv6_prefix, 56,
7853            "subnet_ipv6_prefix must survive the TOML round trip — a swap with the IPv6 \
7854             key would leave a cap that silently never fires"
7855        );
7856        assert_eq!(
7857            config.max_connections_per_ip, 10,
7858            "the pre-existing per-IP cap must be unaffected: the two are independent"
7859        );
7860
7861        let cluster = config
7862            .clusters
7863            .get("api")
7864            .expect("the staged cluster must be present");
7865        let override_value = match cluster {
7866            ClusterConfig::Http(http) => http.max_connections_per_subnet,
7867            other => panic!("expected an HTTP cluster, got {other:?}"),
7868        };
7869        assert_eq!(
7870            override_value,
7871            Some(7),
7872            "the per-cluster subnet override must reach the cluster config"
7873        );
7874    }
7875
7876    /// The backend connection attempt budget (sozu-proxy/sozu#1800): five by
7877    /// default, set globally and per cluster from TOML, carried to the worker
7878    /// in `ServerConfig` and to the cluster in its `AddCluster`.
7879    #[test]
7880    fn max_connection_attempts_defaults_to_five_and_round_trips_from_toml() {
7881        let config_with = |global: &str, cluster: &str| -> Config {
7882            let toml_content = format!(
7883                r#"
7884            command_socket = "/tmp/sozu.sock"
7885            saved_state    = "./state.json"
7886            worker_count   = 1
7887            {global}
7888
7889            [[listeners]]
7890            protocol = "http"
7891            address  = "127.0.0.1:8080"
7892
7893            [clusters.api]
7894            protocol       = "http"
7895            load_balancing = "ROUND_ROBIN"
7896            {cluster}
7897            frontends = [
7898              {{ address = "127.0.0.1:8080", hostname = "example.com" }}
7899            ]
7900            backends = [ {{ address = "10.0.0.1:8080" }} ]
7901        "#
7902            );
7903            let file_config: FileConfig =
7904                toml::from_str(&toml_content).expect("Could not parse TOML config");
7905            ConfigBuilder::new(file_config, "/tmp/test_config.toml")
7906                .into_config()
7907                .expect("a valid attempt budget must load")
7908        };
7909        let cluster_attempts = |config: &Config| match config.clusters.get("api") {
7910            Some(ClusterConfig::Http(http)) => http.max_connection_attempts,
7911            other => panic!("expected the HTTP cluster, got {other:?}"),
7912        };
7913        let add_cluster_attempts = |config: &Config| {
7914            config
7915                .generate_config_messages()
7916                .expect("Could not generate config messages")
7917                .into_iter()
7918                .find_map(|message| match message.content.request_type {
7919                    Some(RequestType::AddCluster(cluster)) => Some(cluster.max_connection_attempts),
7920                    _ => None,
7921                })
7922                .expect("the cluster is added")
7923        };
7924
7925        let defaults = config_with("", "");
7926        assert_eq!(DEFAULT_MAX_CONNECTION_ATTEMPTS, 5);
7927        assert_eq!(defaults.max_connection_attempts, 5);
7928        assert_eq!(
7929            ServerConfig::from(&defaults).max_connection_attempts,
7930            Some(5),
7931            "the worker receives the default"
7932        );
7933        assert_eq!(
7934            cluster_attempts(&defaults),
7935            None,
7936            "a cluster inherits by default"
7937        );
7938        assert_eq!(add_cluster_attempts(&defaults), None);
7939
7940        let set = config_with("max_connection_attempts = 2", "max_connection_attempts = 7");
7941        assert_eq!(set.max_connection_attempts, 2);
7942        assert_eq!(ServerConfig::from(&set).max_connection_attempts, Some(2));
7943        assert_eq!(cluster_attempts(&set), Some(7));
7944        assert_eq!(add_cluster_attempts(&set), Some(7));
7945    }
7946
7947    /// A budget allowing no attempt, or more than the attempt counters hold,
7948    /// is rejected at load, globally and per cluster; both bounds are
7949    /// inclusive.
7950    #[test]
7951    fn an_out_of_range_max_connection_attempts_is_rejected() {
7952        let load = |global: &str, cluster: &str| {
7953            let toml_content = format!(
7954                r#"
7955            command_socket = "/tmp/sozu.sock"
7956            saved_state    = "./state.json"
7957            worker_count   = 1
7958            {global}
7959
7960            [[listeners]]
7961            protocol = "http"
7962            address  = "127.0.0.1:8080"
7963
7964            [clusters.api]
7965            protocol       = "http"
7966            load_balancing = "ROUND_ROBIN"
7967            {cluster}
7968            frontends = [
7969              {{ address = "127.0.0.1:8080", hostname = "example.com" }}
7970            ]
7971            backends = [ {{ address = "10.0.0.1:8080" }} ]
7972        "#
7973            );
7974            let file_config: FileConfig =
7975                toml::from_str(&toml_content).expect("Could not parse TOML config");
7976            ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config()
7977        };
7978        for value in ["0", "256"] {
7979            let global = format!("max_connection_attempts = {value}");
7980            assert!(
7981                matches!(
7982                    load(&global, ""),
7983                    Err(ConfigError::InvalidMaxConnectionAttempts {
7984                        cluster_id: None,
7985                        ..
7986                    })
7987                ),
7988                "a global budget of {value} must be rejected"
7989            );
7990            assert!(
7991                matches!(
7992                    load("", &global),
7993                    Err(ConfigError::InvalidMaxConnectionAttempts {
7994                        cluster_id: Some(_),
7995                        ..
7996                    })
7997                ),
7998                "a cluster budget of {value} must be rejected"
7999            );
8000        }
8001        for value in ["1", "255"] {
8002            let budget = format!("max_connection_attempts = {value}");
8003            assert!(load(&budget, &budget).is_ok(), "{value} is in range");
8004        }
8005    }
8006
8007    /// An out-of-range prefix is REJECTED, never silently clamped. A
8008    /// narrowed mask would enforce a policy other than the one written
8009    /// in the file, and nothing in the config would say so.
8010    #[test]
8011    fn an_out_of_range_subnet_prefix_is_rejected_not_clamped() {
8012        let with_prefixes = |v4: &str, v6: &str| -> String {
8013            format!(
8014                r#"
8015            command_socket = "/tmp/sozu.sock"
8016            saved_state    = "./state.json"
8017            worker_count   = 1
8018
8019            max_connections_per_subnet = 100
8020            subnet_ipv4_prefix         = {v4}
8021            subnet_ipv6_prefix         = {v6}
8022
8023            [[listeners]]
8024            protocol = "http"
8025            address  = "127.0.0.1:8080"
8026        "#
8027            )
8028        };
8029
8030        for (v4, v6, key) in [
8031            ("33", "56", "subnet_ipv4_prefix"),
8032            ("24", "129", "subnet_ipv6_prefix"),
8033        ] {
8034            let file_config: FileConfig =
8035                toml::from_str(&with_prefixes(v4, v6)).expect("Could not parse TOML config");
8036            let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8037            match result {
8038                Err(ConfigError::InvalidSubnetPrefix { key: reported, .. }) => {
8039                    assert_eq!(
8040                        reported, key,
8041                        "the rejection must name the key that is out of range"
8042                    );
8043                }
8044                other => panic!("{key} out of range must be rejected, got {other:?}"),
8045            }
8046        }
8047
8048        // Positive space: both bounds are INCLUSIVE, so the widest legal
8049        // pair must still load.
8050        let file_config: FileConfig =
8051            toml::from_str(&with_prefixes("32", "128")).expect("Could not parse TOML config");
8052        assert!(
8053            ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8054                .into_config()
8055                .is_ok(),
8056            "the full-width prefixes are legal and are the defaults — they must load"
8057        );
8058    }
8059
8060    #[test]
8061    fn old_state_file_without_client_auth_fields_deserializes() {
8062        // A state file written before mTLS landed carries no `client_ca_*`
8063        // fields. `#[serde(default)]` (via build.rs) must let it round-trip,
8064        // defaulting the new repeated fields to empty rather than erroring on a
8065        // missing field.
8066        let address = SocketAddress::new_v4(127, 0, 0, 1, 8443);
8067        let config = ListenerBuilder::new_https(address)
8068            .to_tls(None)
8069            .expect("default HTTPS listener config");
8070
8071        let mut json: serde_json::Value =
8072            serde_json::to_value(&config).expect("serialize HttpsListenerConfig");
8073        let obj = json.as_object_mut().expect("object");
8074        obj.remove("client_ca_certificates");
8075        obj.remove("client_ca_crls");
8076        obj.remove("client_auth");
8077
8078        let restored: HttpsListenerConfig =
8079            serde_json::from_value(json).expect("old state file must deserialize");
8080        assert!(restored.client_ca_certificates.is_empty());
8081        assert!(restored.client_ca_crls.is_empty());
8082        assert_eq!(restored.client_auth, None);
8083    }
8084
8085    #[test]
8086    fn client_auth_fields_rejected_on_non_https_listener() {
8087        // A TCP listener carrying client_auth must be rejected, not silently
8088        // started unauthenticated.
8089        let address = SocketAddress::new_v4(127, 0, 0, 1, 9000);
8090        let mut tcp = ListenerBuilder::new_tcp(address);
8091        tcp.client_auth = Some(ClientAuthConfig::Required);
8092        assert!(matches!(
8093            tcp.to_tcp(None),
8094            Err(ConfigError::ClientAuthOnNonHttps(_))
8095        ));
8096
8097        // A CA bundle without an explicit mode is equally a misconfiguration.
8098        let mut http = ListenerBuilder::new_http(address);
8099        http.client_ca_certificates = Some(vec!["ca.pem".to_owned()]);
8100        assert!(matches!(
8101            http.to_http(None),
8102            Err(ConfigError::ClientAuthOnNonHttps(_))
8103        ));
8104    }
8105
8106    #[test]
8107    fn none_mode_ignores_stale_ca_paths() {
8108        // With client auth disabled, an unreadable CA/CRL path must not block
8109        // the HTTPS listener from materializing.
8110        let address = SocketAddress::new_v4(127, 0, 0, 1, 9443);
8111        let mut https = ListenerBuilder::new_https(address);
8112        https.client_auth = Some(ClientAuthConfig::None);
8113        https.client_ca_certificates = Some(vec!["/nonexistent/ca.pem".to_owned()]);
8114        https.client_ca_crls = Some(vec!["/nonexistent/crl.pem".to_owned()]);
8115        let config = https.to_tls(None).expect("NONE mode must ignore CA paths");
8116        assert!(config.client_ca_certificates.is_empty());
8117        assert!(config.client_ca_crls.is_empty());
8118    }
8119
8120    /// The configuration file names the CA and CRL files; the main process
8121    /// reads them at config-load and sends their contents to the workers, on
8122    /// the verb a worker that predates mutual TLS cannot decode.
8123    #[test]
8124    fn client_auth_listener_inlines_its_ca_and_crl_files_into_the_listener_request() {
8125        let asset =
8126            |name: &str| format!("{}/../lib/assets/mtls/{name}", env!("CARGO_MANIFEST_DIR"));
8127        let (ca_path, crl_path) = (asset("ca-cert.pem"), asset("crl-revoked.pem"));
8128        let toml_content = format!(
8129            r#"
8130            command_socket = "/tmp/sozu.sock"
8131            saved_state    = "./state.json"
8132            worker_count   = 1
8133
8134            [[listeners]]
8135            protocol = "https"
8136            address  = "127.0.0.1:8443"
8137            client_auth = "required"
8138            client_ca_certificates = ["{ca_path}"]
8139            client_ca_crls = ["{crl_path}"]
8140        "#
8141        );
8142        let file_config: FileConfig =
8143            toml::from_str(&toml_content).expect("Could not parse TOML config");
8144        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8145            .into_config()
8146            .expect("a client auth listener with readable CA and CRL files must load");
8147
8148        let listeners: Vec<HttpsListenerConfig> = config
8149            .generate_config_messages()
8150            .expect("the config must produce its worker requests")
8151            .into_iter()
8152            .filter_map(|request| match request.content.request_type {
8153                Some(RequestType::AddHttpsListenerWithClientAuth(listener)) => Some(listener),
8154                Some(RequestType::AddHttpsListener(_)) => {
8155                    panic!("a client auth listener must not go out as AddHttpsListener")
8156                }
8157                _ => None,
8158            })
8159            .collect();
8160        assert_eq!(listeners.len(), 1, "one HTTPS listener is configured");
8161        let listener = &listeners[0];
8162        assert_eq!(
8163            listener.client_auth,
8164            Some(ClientAuthMode::ClientAuthRequired as i32)
8165        );
8166        assert_eq!(
8167            listener.client_ca_certificates,
8168            vec![std::fs::read_to_string(&ca_path).expect("read the CA fixture")],
8169            "the CA file content, not its path, must reach the worker"
8170        );
8171        assert_eq!(
8172            listener.client_ca_crls,
8173            vec![std::fs::read_to_string(&crl_path).expect("read the CRL fixture")],
8174            "the CRL file content, not its path, must reach the worker"
8175        );
8176        assert!(
8177            listener.client_ca_crls[0].contains("BEGIN X509 CRL"),
8178            "the inlined CRL is the PEM body"
8179        );
8180    }
8181
8182    #[test]
8183    fn client_auth_listener_with_an_unreadable_crl_file_does_not_load() {
8184        // A CRL the operator configured but Sozu cannot read would silently
8185        // disable the revocation they asked for.
8186        let address = SocketAddress::new_v4(127, 0, 0, 1, 9443);
8187        let mut https = ListenerBuilder::new_https(address);
8188        https.client_auth = Some(ClientAuthConfig::Required);
8189        https.client_ca_certificates = Some(vec![format!(
8190            "{}/../lib/assets/mtls/ca-cert.pem",
8191            env!("CARGO_MANIFEST_DIR")
8192        )]);
8193        https.client_ca_crls = Some(vec!["/nonexistent/crl.pem".to_owned()]);
8194        assert!(
8195            https.to_tls(None).is_err(),
8196            "an unreadable CRL file must fail the listener, not drop the revocation check"
8197        );
8198    }
8199
8200    #[test]
8201    fn client_auth_files_without_a_mode_are_rejected() {
8202        // The mode defaults to `none`, which ignores these files: a listener
8203        // naming a CA or a CRL with no mode would start unauthenticated.
8204        let address = SocketAddress::new_v4(127, 0, 0, 1, 9443);
8205        for (cas, crls) in [
8206            (Some(vec!["/etc/sozu/client-ca.pem".to_owned()]), None),
8207            (None, Some(vec!["/etc/sozu/client-ca.crl.pem".to_owned()])),
8208        ] {
8209            let mut https = ListenerBuilder::new_https(address);
8210            https.client_ca_certificates = cas;
8211            https.client_ca_crls = crls;
8212            assert!(matches!(
8213                https.to_tls(None),
8214                Err(ConfigError::ClientAuthModeMissing(_))
8215            ));
8216        }
8217        // Positive space: an explicit `none` keeps the files unused (and is
8218        // only warned about), and no file at all needs no mode.
8219        let mut explicit_none = ListenerBuilder::new_https(address);
8220        explicit_none.client_auth = Some(ClientAuthConfig::None);
8221        explicit_none.client_ca_certificates = Some(vec!["/nonexistent/ca.pem".to_owned()]);
8222        assert!(explicit_none.to_tls(None).is_ok());
8223        assert!(ListenerBuilder::new_https(address).to_tls(None).is_ok());
8224    }
8225
8226    #[test]
8227    fn client_auth_config_parses_documented_lowercase_names() {
8228        // The documented TOML values (`none`/`optional`/`required`) must parse,
8229        // not just the protobuf enum's SCREAMING_SNAKE_CASE.
8230        assert_eq!(
8231            serde_json::from_str::<ClientAuthConfig>("\"required\"").unwrap(),
8232            ClientAuthConfig::Required
8233        );
8234        assert_eq!(
8235            serde_json::from_str::<ClientAuthConfig>("\"optional\"").unwrap(),
8236            ClientAuthConfig::Optional
8237        );
8238        assert_eq!(
8239            serde_json::from_str::<ClientAuthConfig>("\"none\"").unwrap(),
8240            ClientAuthConfig::None
8241        );
8242        assert_eq!(
8243            ClientAuthMode::from(ClientAuthConfig::Required),
8244            ClientAuthMode::ClientAuthRequired
8245        );
8246    }
8247}