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, ToSocketAddrs},
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, HeaderMatchKind, HeaderPosition,
68        HeaderPredicate, HealthCheckConfig, HealthCheckHeader, HealthCheckHeaderValue,
69        HealthCheckMode, HstsConfig, HttpListenerConfig, HttpStatusRange, HttpsListenerConfig,
70        ListenerType, LoadBalancingAlgorithms, LoadBalancingParams, LoadMetric, MetricDetail,
71        MetricsConfiguration, PathRule, ProtobufAccessLogFormat, ProxyProtocolConfig,
72        RedirectPolicy, RedirectScheme, Request, RequestHttpFrontend, RequestTcpFrontend,
73        RequestUdpFrontend, RulePosition, ServerConfig, ServerMetricsConfig, ShardMode,
74        SocketAddress, TcpListenerConfig, TlsVersion, UdpAffinityKey, UdpClusterConfig,
75        UdpHealthConfig, UdpHealthMode, UdpListenerConfig, WorkerRequest, request::RequestType,
76    },
77};
78
79/// Authoritative list of default cipher suites for all rustls-based TLS providers.
80///
81/// These use rustls naming conventions and are supported by all three crypto providers
82/// (ring, aws-lc-rs, rustls-openssl). Order follows ANSSI recommendations: AES-256
83/// preferred over AES-128, ECDSA preferred over RSA, TLS 1.3 preferred over TLS 1.2.
84///
85/// See the [documentation](https://docs.rs/rustls/latest/rustls/static.ALL_CIPHER_SUITES.html)
86pub const DEFAULT_CIPHER_LIST: [&str; 9] = [
87    // TLS 1.3 cipher suites
88    "TLS13_AES_256_GCM_SHA384",
89    "TLS13_AES_128_GCM_SHA256",
90    "TLS13_CHACHA20_POLY1305_SHA256",
91    // TLS 1.2 cipher suites
92    "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
93    "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
94    "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
95    "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
96    "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
97    "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
98];
99
100pub const DEFAULT_SIGNATURE_ALGORITHMS: [&str; 9] = [
101    "ECDSA+SHA256",
102    "ECDSA+SHA384",
103    "ECDSA+SHA512",
104    "RSA+SHA256",
105    "RSA+SHA384",
106    "RSA+SHA512",
107    "RSA-PSS+SHA256",
108    "RSA-PSS+SHA384",
109    "RSA-PSS+SHA512",
110];
111
112pub const DEFAULT_GROUPS_LIST: [&str; 4] = ["X25519MLKEM768", "x25519", "P-256", "P-384"];
113
114/// Default ALPN protocols advertised by HTTPS listeners.
115/// Both HTTP/2 and HTTP/1.1 are enabled, allowing clients to negotiate either.
116pub const DEFAULT_ALPN_PROTOCOLS: [&str; 2] = ["h2", "http/1.1"];
117
118/// maximum time of inactivity for a frontend socket (60 seconds)
119pub const DEFAULT_FRONT_TIMEOUT: u32 = 60;
120
121/// permission bits given to the unix command socket when `command_socket_mode`
122/// is absent: owner read/write only, the mode Sōzu has always used
123pub const DEFAULT_COMMAND_SOCKET_MODE: u32 = 0o600;
124
125/// every permission bit `command_socket_mode` may set. setuid, setgid and the
126/// sticky bit mean nothing on a socket and are refused.
127pub const MAX_COMMAND_SOCKET_MODE: u32 = 0o777;
128
129/// maximum time of inactivity for a backend socket (30 seconds)
130pub const DEFAULT_BACK_TIMEOUT: u32 = 30;
131
132/// maximum time to connect to a backend server (3 seconds)
133pub const DEFAULT_CONNECT_TIMEOUT: u32 = 3;
134
135/// maximum time allowed to receive enough bytes of the TLS ClientHello to
136/// read the SNI extension on a TCP listener (5 seconds). Only relevant when
137/// at least one SNI-scoped `TcpFrontendConfig` targets the listener. Must
138/// match the proto default on `TcpListenerConfig.sni_preread_timeout`.
139pub const DEFAULT_SNI_PREREAD_TIMEOUT: u32 = 5;
140
141/// maximum number of bytes buffered while prereading the TLS ClientHello
142/// looking for the SNI extension on a TCP listener (16 KB — matches the
143/// H2 frame ceiling used elsewhere in this file). Only relevant when at
144/// least one SNI-scoped `TcpFrontendConfig` targets the listener; clamped by
145/// the global `buffer_size` (see `ConfigError::SniPrereadMaxBytesExceedsBufferSize`).
146/// Must match the proto default on
147/// `TcpListenerConfig.sni_preread_max_bytes`.
148pub const DEFAULT_SNI_PREREAD_MAX_BYTES: u32 = 16384;
149
150/// minimum allowed `sni_preread_max_bytes` on a TCP listener targeted by an
151/// SNI frontend (5 bytes — a full TLS record header: 1-byte `ContentType` +
152/// 2-byte `ProtocolVersion` + 2-byte length, RFC 8446 §5.1). `0` (and every
153/// value below this floor) makes the preread shell issue reads that can
154/// never accumulate enough bytes to parse even the outer record framing,
155/// spinning until the event-loop iteration guard (`MAX_LOOP_ITERATIONS`)
156/// trips instead of ever reaching a routing decision. See
157/// `ConfigError::SniPrereadMaxBytesTooSmall`.
158pub const MIN_SNI_PREREAD_MAX_BYTES: u32 = 5;
159
160/// maximum time to receive a request since the connection started (10 seconds)
161pub const DEFAULT_REQUEST_TIMEOUT: u32 = 10;
162
163/// client/upstream flow idle timeout for a UDP listener (30 seconds)
164pub const DEFAULT_UDP_FRONT_TIMEOUT: u32 = 30;
165
166/// upstream flow idle timeout for a UDP listener (30 seconds)
167pub const DEFAULT_UDP_BACK_TIMEOUT: u32 = 30;
168
169/// maximum received datagram size for a UDP listener, in bytes (1500 = a
170/// typical Ethernet MTU). Capped at the effective `buffer_size` at runtime.
171pub const DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE: u32 = 1500;
172
173/// maximum number of concurrent UDP flows per listener. `0` selects the
174/// runtime auto policy (~70% of the soft `RLIMIT_NOFILE`).
175pub const DEFAULT_UDP_MAX_FLOWS: u32 = 0;
176
177/// maximum time to wait for a worker to respond, until it is deemed NotAnswering (10 seconds)
178pub const DEFAULT_WORKER_TIMEOUT: u32 = 10;
179
180/// a name applied to sticky sessions ("SOZUBALANCEID")
181pub const DEFAULT_STICKY_NAME: &str = "SOZUBALANCEID";
182
183/// Interval between checking for zombie sessions, (30 minutes)
184pub const DEFAULT_ZOMBIE_CHECK_INTERVAL: u32 = 1_800;
185
186/// timeout to accept connection events in the accept queue (60 seconds)
187pub const DEFAULT_ACCEPT_QUEUE_TIMEOUT: u32 = 60;
188
189/// Default `Strict-Transport-Security: max-age` value (1 year, 31_536_000
190/// seconds) substituted at config-load when an `[hsts]` block sets
191/// `enabled = true` but omits `max_age`. Matches the HSTS preload list
192/// minimum (<https://hstspreload.org/>) and the Caddy / Nginx community
193/// recommendation. Operators can override with any `u32`; `max_age = 0`
194/// is the RFC 6797 §11.4 kill switch and is allowed silently.
195pub const DEFAULT_HSTS_MAX_AGE: u32 = 31_536_000;
196
197/// whether to evict least-recently-active sessions when the accept queue is
198/// saturated (false). Defaults to false because during a DDoS the existing
199/// connections are more likely to be legitimate clients than the queued ones;
200/// evicting them would serve the attacker. Enable when overload is dominated
201/// by normal traffic spikes rather than attacks.
202pub const DEFAULT_EVICT_ON_QUEUE_FULL: bool = false;
203
204/// number of workers, i.e. Sōzu processes that scale horizontally (2)
205pub const DEFAULT_WORKER_COUNT: u16 = 2;
206
207/// wether a worker is automatically restarted when it crashes (true)
208pub const DEFAULT_WORKER_AUTOMATIC_RESTART: bool = true;
209
210/// wether to save the state automatically (false)
211pub const DEFAULT_AUTOMATIC_STATE_SAVE: bool = false;
212
213/// minimum number of buffers (1)
214pub const DEFAULT_MIN_BUFFERS: u64 = 1;
215
216/// maximum number of buffers (1 000)
217pub const DEFAULT_MAX_BUFFERS: u64 = 1_000;
218
219/// size of the buffers, in bytes (16 KB)
220pub const DEFAULT_BUFFER_SIZE: u64 = 16_393;
221
222/// minimum buffer size required when any HTTPS listener advertises H2 ALPN.
223///
224/// RFC 9113 §6.5.2 caps `SETTINGS_MAX_FRAME_SIZE` at 16 384 bytes by default;
225/// the on-wire H2 frame header is a fixed 9 bytes (§4.1), so the kawa storage
226/// must be able to hold 16 384 + 9 = 16 393 bytes before forwarding. A smaller
227/// `buffer_size` causes the H2 mux to deadlock on full-size frames (no panic,
228/// no obvious log) until the session timeout fires. Validated at config-load
229/// time in `ConfigBuilder::into_config` so a typo in TOML is rejected at boot,
230/// not discovered under traffic.
231pub const H2_MIN_BUFFER_SIZE: u64 = 16_393;
232
233/// maximum number of simultaneous connections (10 000)
234pub const DEFAULT_MAX_CONNECTIONS: usize = 10_000;
235
236/// size of the buffer for the channels, in bytes. Must be bigger than the size of the data received. (1 MB)
237pub const DEFAULT_COMMAND_BUFFER_SIZE: u64 = 1_000_000;
238
239/// maximum size of the buffer for the channels, in bytes. (2 MB)
240pub const DEFAULT_MAX_COMMAND_BUFFER_SIZE: u64 = 2_000_000;
241
242/// wether to avoid register cluster metrics in the local drain
243pub const DEFAULT_DISABLE_CLUSTER_METRICS: bool = false;
244
245pub const MAX_LOOP_ITERATIONS: usize = 100000;
246
247/// Number of TLS 1.3 tickets to send to a client when establishing a connection.
248/// The tickets allow the client to resume a session. This protects the client
249/// agains session tracking. Increases the number of getrandom syscalls,
250/// with little influence on performance. Defaults to 4.
251pub const DEFAULT_SEND_TLS_13_TICKETS: u64 = 4;
252
253/// for both logs and access logs
254pub const DEFAULT_LOG_TARGET: &str = "stdout";
255
256/// Default per-(cluster, source-IP) connection limit. `0` means unlimited.
257/// Counts are kept per `(cluster_id, source_ip)` so two clusters never
258/// share a counter even from the same IP. Per-cluster overrides on the
259/// `Cluster` message take precedence.
260pub const DEFAULT_MAX_CONNECTIONS_PER_IP: u64 = 0;
261
262/// Default `Retry-After` header value (seconds) on HTTP 429 responses
263/// emitted when a per-(cluster, source-IP) connection limit is hit. `0`
264/// omits the header — `Retry-After: 0` invites an immediate retry that
265/// defeats the limit. TCP rejections do not emit this value (no HTTP
266/// envelope), but the field is accepted for symmetry.
267pub const DEFAULT_RETRY_AFTER: u32 = 60;
268
269/// Default per-(cluster, source-SUBNET) connection limit. `0` means
270/// unlimited, which disables the subnet limiter entirely and leaves the
271/// pre-existing per-IP behaviour strictly unchanged. This is a SECOND,
272/// independent counter beside [`DEFAULT_MAX_CONNECTIONS_PER_IP`], not a
273/// replacement: the per-IP cap serves the API-gateway case, the
274/// per-subnet cap the denial-of-service case where an attacker holding a
275/// routed prefix takes a fresh address per connection
276/// (sozu-proxy/sozu#1270). Both gates are consulted and both must admit.
277pub const DEFAULT_MAX_CONNECTIONS_PER_SUBNET: u64 = 0;
278
279/// Default backend connection attempt budget of a request: how many backend
280/// connections it may try, the first included, before it is answered `503`
281/// (HTTP) or closed (TCP). Every failed connection counts — refused, timed
282/// out on `connect_timeout`, or reporting a socket error. Replaces the
283/// hard-coded `CONN_RETRIES = 3` of `sozu-lib`; the default rose to 5 with
284/// sozu-proxy/sozu#1800, so a request survives more dead backends in a row.
285pub const DEFAULT_MAX_CONNECTION_ATTEMPTS: u32 = 5;
286
287/// Inclusive upper bound of `max_connection_attempts`, global or per
288/// cluster: the attempt counters are `u8`.
289pub const MAX_CONNECTION_ATTEMPTS_UPPER_BOUND: u32 = u8::MAX as u32;
290
291/// Validate a `max_connection_attempts` value, global or per cluster: at least
292/// one attempt, at most [`MAX_CONNECTION_ATTEMPTS_UPPER_BOUND`]. Called at
293/// config load, on every `AddCluster` and on `SetMaxConnectionAttempts`.
294pub fn validate_max_connection_attempts(attempts: u32) -> Result<(), &'static str> {
295    if (1..=MAX_CONNECTION_ATTEMPTS_UPPER_BOUND).contains(&attempts) {
296        Ok(())
297    } else {
298        Err("max_connection_attempts must lie in 1..=255")
299    }
300}
301
302/// Why a cluster whose health-check header values live in files yields no
303/// order: the protected activation that reads them does not exist yet.
304pub const HEALTH_CHECK_VALUE_FILE_UNSUPPORTED: &str = "health check header value_file references cannot be applied yet: the protected activation of their values is not available";
305
306/// Why a health check `port` and a backend with a `unix_path` never meet in
307/// one cluster: `port` names a TCP port on a backend IP, which a UNIX socket
308/// does not have, so the probe endpoint would be ambiguous.
309pub const HEALTH_CHECK_PORT_UNIX_REFUSED: &str = "health_check.port targets a backend IP address and cannot be combined with a unix_path backend; unset the probe port or the unix_path";
310
311/// Parse a backend's `unix_path` into the socket address sozu dials: an
312/// absolute filesystem path, or `@name` for a Linux abstract-namespace
313/// socket. Called at config load, on every `AddBackend` the main process
314/// accepts and on every `AddBackend` a worker applies, so all three refuse
315/// the same values.
316pub fn parse_backend_unix_path(
317    unix_path: &str,
318) -> Result<std::os::unix::net::SocketAddr, &'static str> {
319    const TOO_LONG: &str = "unix_path does not fit a sockaddr_un (107 bytes at most)";
320    if unix_path.as_bytes().contains(&0) {
321        return Err("unix_path must not contain a NUL byte");
322    }
323    let address = if let Some(name) = unix_path.strip_prefix('@') {
324        if name.is_empty() {
325            return Err("an abstract unix_path needs a name after '@'");
326        }
327        parse_abstract_unix_name(name.as_bytes())
328            .map_err(|unsupported| unsupported.unwrap_or(TOO_LONG))?
329    } else if unix_path.starts_with('/') {
330        std::os::unix::net::SocketAddr::from_pathname(unix_path).map_err(|_| TOO_LONG)?
331    } else {
332        return Err("unix_path must be an absolute path or an @abstract name");
333    };
334    // POST: a pathname parses to a pathname address and `@name` to an
335    // abstract one, never the unnamed address that dials nothing.
336    debug_assert!(
337        !address.is_unnamed(),
338        "a parsed unix_path never yields an unnamed address"
339    );
340    debug_assert_eq!(
341        address.as_pathname().is_some(),
342        !unix_path.starts_with('@'),
343        "only a path without '@' parses to a pathname address"
344    );
345    Ok(address)
346}
347
348/// `Err(None)` when `name` is too long, `Err(Some(reason))` when the platform
349/// has no abstract namespace.
350#[cfg(any(target_os = "linux", target_os = "android"))]
351fn parse_abstract_unix_name(
352    name: &[u8],
353) -> Result<std::os::unix::net::SocketAddr, Option<&'static str>> {
354    #[cfg(target_os = "android")]
355    use std::os::android::net::SocketAddrExt;
356    #[cfg(target_os = "linux")]
357    use std::os::linux::net::SocketAddrExt;
358    std::os::unix::net::SocketAddr::from_abstract_name(name).map_err(|_| None)
359}
360
361#[cfg(not(any(target_os = "linux", target_os = "android")))]
362fn parse_abstract_unix_name(
363    _name: &[u8],
364) -> Result<std::os::unix::net::SocketAddr, Option<&'static str>> {
365    Err(Some("an @abstract unix_path needs Linux"))
366}
367
368/// Default IPv4 prefix length (bits) used to derive the subnet key. 32
369/// masks nothing, so enabling only `max_connections_per_subnet` yields a
370/// per-address counter rather than a surprising aggregation. `/24` is the
371/// recommended starting point; see `doc/configure.md`.
372pub const DEFAULT_SUBNET_IPV4_PREFIX: u32 = 32;
373
374/// Default IPv6 prefix length (bits) used to derive the subnet key. 128
375/// masks nothing. `/56` is the recommended starting point; see
376/// `doc/configure.md`.
377pub const DEFAULT_SUBNET_IPV6_PREFIX: u32 = 128;
378
379/// Inclusive upper bound for [`DEFAULT_SUBNET_IPV4_PREFIX`] and the
380/// `subnet_ipv4_prefix` config key. A value above this is a config error,
381/// never silently clamped.
382pub const MAX_SUBNET_IPV4_PREFIX: u32 = 32;
383
384/// Inclusive upper bound for [`DEFAULT_SUBNET_IPV6_PREFIX`] and the
385/// `subnet_ipv6_prefix` config key. A value above this is a config error,
386/// never silently clamped.
387pub const MAX_SUBNET_IPV6_PREFIX: u32 = 128;
388
389#[derive(Debug)]
390pub enum IncompatibilityKind {
391    PublicAddress,
392    ProxyProtocol,
393}
394
395#[derive(Debug)]
396pub enum MissingKind {
397    Field(String),
398    Protocol,
399    SavedState,
400}
401
402#[derive(thiserror::Error, Debug)]
403pub enum ConfigError {
404    #[error("env path not found: {0}")]
405    Env(String),
406    #[error("Could not open file {path_to_open}: {io_error}")]
407    FileOpen {
408        path_to_open: String,
409        io_error: std::io::Error,
410    },
411    #[error("Could not read file {path_to_read}: {io_error}")]
412    FileRead {
413        path_to_read: String,
414        io_error: std::io::Error,
415    },
416    #[error(
417        "the field {kind:?} of {object:?} with id or address {id} is incompatible with the rest of the options"
418    )]
419    Incompatible {
420        kind: IncompatibilityKind,
421        object: ObjectKind,
422        id: String,
423    },
424    #[error("Invalid '{0}' field for a TCP frontend")]
425    InvalidFrontendConfig(String),
426    #[error("invalid path {0:?}")]
427    InvalidPath(PathBuf),
428    #[error("listening address {0:?} is already used in the configuration")]
429    ListenerAddressAlreadyInUse(SocketAddr),
430    #[error("listener {0} is already declared in the configuration")]
431    ListenerAlreadyDeclared(ListenerKey),
432    #[error(
433        "listeners sharing the address {0} on different interfaces must all use the same protocol"
434    )]
435    ListenerProtocolMismatchOnAddress(SocketAddr),
436    #[error("listener {address}: {error}")]
437    ListenerInterface {
438        address: SocketAddr,
439        error: InterfaceError,
440    },
441    #[error("missing {0:?}")]
442    Missing(MissingKind),
443    #[error("could not get parent directory for file {0}")]
444    NoFileParent(String),
445    #[error("Could not get the path of the saved state")]
446    SaveStatePath(String),
447    #[error("Can not determine path to sozu socket: {0}")]
448    SocketPathError(String),
449    #[error("toml decoding error: {0}")]
450    DeserializeToml(String),
451    #[error("Can not set this frontend on a {0:?} listener")]
452    WrongFrontendProtocol(ListenerProtocol),
453    #[error("Can not build a {expected:?} listener from a {found:?} config")]
454    WrongListenerProtocol {
455        expected: ListenerProtocol,
456        found: Option<ListenerProtocol>,
457    },
458    #[error("Invalid ALPN protocol '{0}'. Valid values: \"h2\", \"http/1.1\"")]
459    InvalidAlpnProtocol(String),
460    /// A cluster's `affinity_header` / `affinity_cookie` breaks
461    /// [`validate_affinity_key`], or is set on a TCP cluster.
462    #[error("cluster {cluster_id}: {reason}")]
463    InvalidAffinityKey {
464        cluster_id: String,
465        reason: &'static str,
466    },
467    /// A cluster's shuffle-sharding knobs break [`validate_shuffle_sharding`].
468    #[error("cluster {cluster_id}: {reason}")]
469    InvalidShuffleSharding {
470        cluster_id: String,
471        reason: &'static str,
472    },
473    /// A `max_connection_attempts`, global (`cluster_id` is `None`) or of a
474    /// cluster, breaks [`validate_max_connection_attempts`].
475    #[error("max_connection_attempts = {value}{}: {reason}", cluster_id.as_deref().map(|id| format!(" in cluster {id}")).unwrap_or_default())]
476    InvalidMaxConnectionAttempts {
477        cluster_id: Option<String>,
478        value: u32,
479        reason: &'static str,
480    },
481    /// A backend's `unix_path` breaks [`parse_backend_unix_path`], shares its
482    /// identity `address` with another backend of the cluster, or sits in a
483    /// UDP cluster.
484    #[error("cluster {cluster_id}, backend {address}: {reason}")]
485    InvalidBackendUnixPath {
486        cluster_id: String,
487        address: SocketAddr,
488        reason: &'static str,
489    },
490    /// A cluster's `health_check` block has an unparsable `accepted_statuses`
491    /// entry or breaks [`validate_health_check_config`].
492    #[error("cluster {cluster_id}: {reason}")]
493    InvalidHealthCheck {
494        cluster_id: String,
495        reason: &'static str,
496    },
497    /// `subnet_ipv4_prefix` / `subnet_ipv6_prefix` is outside the bit
498    /// width of its address family. Rejected rather than clamped: a
499    /// silently narrowed mask would enforce a different policy than the
500    /// one written in the file, and the operator would have no way to
501    /// tell from the config which one is in force.
502    #[error(
503        "{key} = {value} is out of range: a {family} prefix length must be between 0 and \
504         {maximum} bits inclusive."
505    )]
506    InvalidSubnetPrefix {
507        key: &'static str,
508        family: &'static str,
509        value: u32,
510        maximum: u32,
511    },
512    /// `command_socket_mode` is not an octal permission string within
513    /// [`MAX_COMMAND_SOCKET_MODE`]. See [`parse_command_socket_mode`].
514    #[error("command_socket_mode = {value:?} is invalid: {reason}")]
515    InvalidCommandSocketMode { value: String, reason: &'static str },
516    /// `disable_http11 = true` and `alpn_protocols` containing `"http/1.1"`
517    /// are mutually exclusive: the proxy advertises `http/1.1` to peers,
518    /// then refuses every connection that negotiates
519    /// it. The combination is a self-DoS at handshake time. Either drop
520    /// `http/1.1` from `alpn_protocols` or unset `disable_http11`.
521    #[error(
522        "disable_http11 = true is incompatible with alpn_protocols containing \"http/1.1\" \
523         on listener {address}. The proxy would advertise http/1.1 then refuse every \
524         connection that negotiates it. Drop \"http/1.1\" from alpn_protocols or unset \
525         disable_http11."
526    )]
527    DisableHttp11WithHttp11Alpn { address: String },
528    /// `buffer_size` is below the H2 minimum (16 393 bytes) but at least one
529    /// HTTPS listener advertises `h2` in its ALPN list. The H2 mux requires
530    /// 16 384-byte frame payload + 9-byte header to fit in a single kawa
531    /// buffer; smaller values deadlock streams that carry full-size frames.
532    /// Either raise `buffer_size` to ≥ 16 393 or remove `h2` from the
533    /// affected listeners' `alpn_protocols`.
534    #[error(
535        "buffer_size = {buffer_size} is below the H2 minimum of {minimum} but \
536         {listeners} listener(s) serve HTTP/2 (an HTTPS listener advertising H2 \
537         ALPN, or an HTTP listener with h2c_prior_knowledge). The H2 mux deadlocks \
538         on full-size frames with smaller buffers. Raise buffer_size to >= {minimum}, \
539         or remove \"h2\" from those HTTPS listeners' alpn_protocols and turn \
540         h2c_prior_knowledge off on those HTTP listeners."
541    )]
542    BufferSizeTooSmallForH2 {
543        buffer_size: u64,
544        minimum: u64,
545        listeners: usize,
546    },
547    /// `command_buffer_size` is greater than `max_command_buffer_size`. The command
548    /// channel (`command/src/channel.rs`) allocates its initial buffer at
549    /// `command_buffer_size` and must never let it exceed `max_command_buffer_size` —
550    /// every growth/shrink path on that channel reasons against that ceiling. Rejected
551    /// at load rather than silently clamped, so the operator sees which of the two
552    /// keys to fix instead of the mistake being rewritten out from under them
553    /// (sozu-proxy/sozu#1416).
554    #[error(
555        "command_buffer_size = {command_buffer_size} exceeds max_command_buffer_size = \
556         {max_command_buffer_size}: lower command_buffer_size to <= {max_command_buffer_size} \
557         or raise max_command_buffer_size to >= {command_buffer_size}"
558    )]
559    CommandBufferSizeExceedsMax {
560        command_buffer_size: u64,
561        max_command_buffer_size: u64,
562    },
563    /// An H2 listener knob was set below the minimum the runtime can honour.
564    /// Every flood-detection threshold needs `>= 1`: `check_flood`
565    /// (`lib/src/protocol/mux/h2_flood_detector.rs`) compares
566    /// `count > threshold`, so `0` does not disable the check, it makes the
567    /// first event the counter sees a violation. `h2_stream_shrink_ratio`
568    /// needs `>= 2` for the same reason its runtime `.max(2)` exists.
569    ///
570    /// What that costs depends on which counter the key guards, and the error
571    /// text says so rather than promising the worst case for all of them:
572    ///
573    /// - `h2_max_header_list_size` and `h2_max_header_fields` are *also* the
574    ///   HPACK decode budget `lib/src/protocol/mux/pkawa.rs`'s
575    ///   `decode_headers_with_budget` enforces. At `0` the first header field
576    ///   of every block already exceeds it (name + value + the 32-octet RFC
577    ///   9113 §6.5.2 overhead), so every request on the listener is refused
578    ///   with `ENHANCE_YOUR_CALM`. Immediate and universal. The frame it
579    ///   arrives in depends on the request: a header block that fits one
580    ///   HEADERS frame is refused per stream (`pkawa.rs`'s
581    ///   `Err((EnhanceYourCalm, false))` → `reset_stream`), while one that
582    ///   spans CONTINUATION frames takes the whole connection down —
583    ///   `record_continuation_frame` lifts `accumulated_header_size` above the
584    ///   zero threshold and `check_flood_or_return!`
585    ///   (`lib/src/protocol/mux/h2.rs`) escalates to
586    ///   `GOAWAY(ENHANCE_YOUR_CALM)` before the explicit size test below it.
587    ///   `h2_max_header_table_size` is NOT in this class: `0` only zeroes the
588    ///   HPACK encoder's dynamic table.
589    /// - The per-window and lifetime frame counters only move when a client
590    ///   sends the frame they count. `h2_max_rst_stream_per_window = 0` is
591    ///   harmless to a connection that never sends RST_STREAM — `0 > 0` is
592    ///   false — and fatal to the first one that does.
593    /// - `h2_max_concurrent_streams` and `h2_stream_shrink_ratio` are not
594    ///   flood counters at all: `H2ConnectionConfig::new`
595    ///   (`lib/src/protocol/mux/h2.rs`) promotes an out-of-range value to the
596    ///   floor behind a `warn!`, which is the silent rewrite this error
597    ///   exists to replace.
598    ///
599    /// Rejected at config load rather than silently clamped, so the operator
600    /// sees which key to fix instead of the mistake being rewritten out from
601    /// under them. `UpdateHttp(s)Listener` already refuses the same values via
602    /// `validate_h2_flood_knobs_http`/`_https` (`command/src/state.rs`); this
603    /// is the matching gate on the config-file / `sozu ctl add listener` path
604    /// (sozu-proxy/sozu#1418).
605    #[error(
606        "listener {address}: {key} = {value} is below the minimum of {minimum} the runtime can \
607         honour. Below the minimum the value does not relax the limit, it breaks it: the H2 flood \
608         detector compares count > threshold, so 0 is already exceeded the first time the counter \
609         it guards leaves zero. How far that reaches depends on the key — \
610         h2_max_header_list_size and h2_max_header_fields are also the HPACK decode budget, so 0 \
611         refuses every request on this listener with ENHANCE_YOUR_CALM (as a stream reset, or as \
612         a connection GOAWAY once a header block spans CONTINUATION frames), while a per-window \
613         or lifetime frame counter only trips once a client sends the frame it counts, and \
614         h2_max_concurrent_streams / h2_stream_shrink_ratio are instead promoted to their floor \
615         behind a warning. Raise {key} to >= {minimum}, or remove it to keep the default."
616    )]
617    H2ThresholdBelowMinimum {
618        address: String,
619        key: &'static str,
620        value: u64,
621        minimum: u64,
622    },
623    /// `redirect = "<value>"` on a frontend used a value the parser doesn't
624    /// recognise. Accepted values are `forward`, `permanent`, `unauthorized`,
625    /// `silent_close` (case-insensitive).
626    #[error(
627        "invalid redirect policy '{0}'. Valid values: \"forward\", \"permanent\", \"unauthorized\", \"silent_close\""
628    )]
629    InvalidRedirectPolicy(String),
630    /// `redirect_scheme = "<value>"` on a frontend used a value the parser
631    /// doesn't recognise. Accepted values are `use-same`, `use-http`,
632    /// `use-https` (case-insensitive).
633    #[error(
634        "invalid redirect scheme '{0}'. Valid values: \"use-same\", \"use-http\", \"use-https\""
635    )]
636    InvalidRedirectScheme(String),
637    /// A `[[clusters.<id>.frontends.headers]]` entry carried an unknown
638    /// `position` value. Accepted values are `request`, `response`, `both`
639    /// (case-insensitive).
640    #[error(
641        "invalid header position '{position}' at headers[{index}]. Valid values: \"request\", \"response\", \"both\""
642    )]
643    InvalidHeaderPosition { index: usize, position: String },
644    /// A `[[clusters.<id>.frontends.headers]]` entry contains a forbidden
645    /// byte (NUL, CR, LF, or another C0 control) in its key or value.
646    /// Accepting these would produce HTTP request/response splitting on
647    /// the wire (CWE-113) — the worker's H2 emission path filters them
648    /// at runtime, but the H1 path serialises raw, so we reject at
649    /// config-load time as a defense in depth.
650    #[error(
651        "invalid header bytes in {field} at headers[{index}]: control characters \
652         (NUL / CR / LF / other C0) are forbidden in header keys and values"
653    )]
654    InvalidHeaderBytes { index: usize, field: &'static str },
655    /// A `header_predicates` entry on a frontend is not a usable routing
656    /// condition (sozu-proxy/sozu#1253); `reason` names what is wrong. The
657    /// regex grammar of a `regex` predicate is checked later, by the
658    /// router, exactly as a `path_type = "regex"` path is.
659    #[error("invalid header predicate at header_predicates[{index}]: {reason}")]
660    InvalidHeaderPredicate { index: usize, reason: &'static str },
661    /// An `[hsts]` block populated `max_age`, `include_subdomains`, or
662    /// `preload` but did not set `enabled`. The TOML representation requires
663    /// `enabled` to be present whenever the block is — that single field
664    /// disambiguates "preserve current" / "explicit disable" / "enable" on
665    /// hot-reconfig partial updates.
666    #[error("invalid HSTS config at {0}: `enabled` is required when an [hsts] block is present")]
667    HstsEnabledRequired(String),
668    /// An `[hsts]` block on an HTTP-only listener or frontend. RFC 6797
669    /// §7.2 forbids emitting `Strict-Transport-Security` over plaintext
670    /// HTTP; sozu rejects the configuration at load time so the
671    /// non-conformant policy never ships to a worker.
672    #[error(
673        "invalid HSTS config at {0}: HSTS is only valid on HTTPS listeners and frontends \
674         (RFC 6797 §7.2 forbids the header over plaintext HTTP)"
675    )]
676    HstsOnPlainHttp(String),
677    /// A client-certificate-authentication (mTLS) field was set on a non-HTTPS
678    /// listener. Client auth is a TLS-termination control; HTTP/TCP/UDP
679    /// listeners discard it, so sozu rejects it at load time rather than
680    /// silently starting an unauthenticated listener.
681    #[error(
682        "invalid client auth config at {0}: client_auth / client_ca_certificates / \
683         client_ca_crls are only valid on HTTPS listeners"
684    )]
685    ClientAuthOnNonHttps(String),
686    /// An HTTPS listener names client CA or CRL files but no `client_auth`
687    /// mode. The mode defaults to `none`, which ignores those files, so the
688    /// listener would start without the client authentication they suggest.
689    #[error(
690        "invalid client auth config at {0}: client_ca_certificates / client_ca_crls are set \
691         but client_auth is not; set client_auth to \"optional\" or \"required\", or to \
692         \"none\" to keep them unused"
693    )]
694    ClientAuthModeMissing(String),
695    /// A TCP frontend's `hostname` (mapped to the wire `sni` field) is
696    /// neither an exact hostname nor a single leading `*.` wildcard label
697    /// (sozu-proxy/sozu#1279). Rejects `*.*.example.com`, an embedded `*`
698    /// anywhere but the leading label, an empty label, and any `/` — never
699    /// valid in a hostname, and a leftmost `/.../` label would otherwise be
700    /// inserted into the `pattern_trie` route table as a REGEX segment.
701    #[error(
702        "invalid SNI pattern '{sni}' for a TCP frontend: expected an exact hostname or a \
703         single leading \"*.\" wildcard label (e.g. \"example.com\" or \"*.example.com\"); \
704         '/' and non-leading '*' are rejected"
705    )]
706    InvalidSniPattern { sni: String },
707    /// A TCP frontend's SNI pattern contains non-ASCII characters. On-wire
708    /// SNI is always an ASCII A-label (RFC 6066 §3 / IDNA), so a Unicode
709    /// U-label in the config would load fine but never match any
710    /// ClientHello — a silent routing failure. Rejected loudly until IDNA
711    /// normalization is supported at config-load; the operator must write
712    /// the punycode A-label form instead.
713    #[error(
714        "non-ASCII SNI pattern '{sni}' for a TCP frontend: on-wire SNI is always an ASCII \
715         A-label (RFC 6066), so this pattern would never match a ClientHello. Write the \
716         punycode A-label form instead (e.g. \"xn--mnchen-3ya.example\" for \
717         \"münchen.example\")"
718    )]
719    NonAsciiSniPattern { sni: String },
720    /// A TCP frontend set `alpn` but left `hostname` (mapped to the wire
721    /// `sni` field) unset. An ALPN matcher only ever gets consulted from
722    /// within the SNI-scoped preread route table; a frontend with no `sni`
723    /// installs the worker's raw no-SNI catch-all path instead
724    /// (`TcpListener::cluster_id`), which never looks at `alpn` at all --
725    /// the configured protocol list would silently never be enforced.
726    /// Reject at config-load rather than mis-routing every connection
727    /// through unconditionally.
728    #[error(
729        "TCP frontend {address} sets alpn but no hostname (sni): alpn only matches within an \
730         SNI-scoped preread, so a frontend without hostname would silently ignore its alpn list. \
731         Set hostname or drop alpn."
732    )]
733    AlpnWithoutSni { address: SocketAddr },
734    /// Two TCP frontends on the same `(address, sni)` advertise an
735    /// overlapping ALPN protocol. Routing on a listener must be
736    /// deterministic: if both frontends could match the same ClientHello,
737    /// which cluster receives the connection would depend on iteration
738    /// order rather than configuration.
739    #[error(
740        "TCP frontends on {address} with sni {sni:?} both match ALPN protocol '{protocol}': \
741         ALPN matchers for the same (address, sni) must not overlap"
742    )]
743    TcpFrontendAlpnOverlap {
744        address: SocketAddr,
745        sni: Option<String>,
746        protocol: String,
747    },
748    /// More than one TCP frontend on the same `(address, sni)` left `alpn`
749    /// empty. An empty `alpn` is the catch-all match for that `sni`; two
750    /// catch-alls on the same `(address, sni)` are as ambiguous as two
751    /// frontends sharing an explicit protocol.
752    #[error(
753        "more than one TCP frontend on {address} with sni {sni:?} leaves alpn empty (the \
754         catch-all match): at most one frontend per (address, sni) may omit alpn"
755    )]
756    TcpFrontendMultipleAlpnCatchAll {
757        address: SocketAddr,
758        sni: Option<String>,
759    },
760    /// A TCP listener address is targeted by both a no-SNI frontend and at
761    /// least one SNI-scoped frontend. An SNI-enabled listener prereads the
762    /// ClientHello before choosing a backend; a raw-TCP fallback frontend
763    /// with no SNI to match against would be unreachable for any client
764    /// that doesn't send SNI, and ambiguous for any that does, so the two
765    /// shapes cannot share a listener.
766    #[error(
767        "TCP listener {address} is targeted by both a no-SNI frontend and at least one \
768         SNI-scoped frontend: an SNI-enabled listener must not also have a raw-TCP fallback \
769         frontend on the same address"
770    )]
771    TcpListenerMixesSniAndNoSni { address: SocketAddr },
772    /// `sni_preread_timeout` on a TCP listener exceeds that listener's
773    /// `front_timeout`. The preread phase is bounded by the frontend's own
774    /// inactivity timeout, so a preread budget longer than the timeout that
775    /// would kill the connection anyway can never fully elapse — it is
776    /// either a config mistake or silently dead configuration.
777    #[error(
778        "sni_preread_timeout = {sni_preread_timeout}s on TCP listener {address} exceeds its \
779         front_timeout = {front_timeout}s: the preread phase cannot outlive the frontend \
780         inactivity timeout that would already have closed the connection"
781    )]
782    SniPrereadTimeoutExceedsFrontTimeout {
783        address: SocketAddr,
784        sni_preread_timeout: u32,
785        front_timeout: u32,
786    },
787    /// `sni_preread_max_bytes` on a TCP listener targeted by at least one
788    /// SNI frontend exceeds the global `buffer_size`. The preread buffer is
789    /// carved out of the same per-session buffer the proxy uses for
790    /// relaying, so a preread ceiling above `buffer_size` can never be
791    /// reached in practice and signals a misconfiguration.
792    #[error(
793        "sni_preread_max_bytes = {sni_preread_max_bytes} on TCP listener {address} exceeds \
794         buffer_size = {buffer_size}: raise buffer_size to >= {sni_preread_max_bytes} or lower \
795         sni_preread_max_bytes"
796    )]
797    SniPrereadMaxBytesExceedsBufferSize {
798        address: SocketAddr,
799        sni_preread_max_bytes: u32,
800        buffer_size: u64,
801    },
802    /// `sni_preread_max_bytes` on a TCP listener targeted by at least one
803    /// SNI frontend is below [`MIN_SNI_PREREAD_MAX_BYTES`]. `0` in
804    /// particular makes the preread shell issue zero-length reads that can
805    /// never make progress -- the session spins until the event-loop
806    /// iteration guard (`MAX_LOOP_ITERATIONS`) trips instead of ever
807    /// completing a preread decision. The floor is a full TLS record
808    /// header (1-byte `ContentType` + 2-byte `ProtocolVersion` + 2-byte
809    /// length, RFC 8446 §5.1): below that, the shell cannot even learn how
810    /// many more bytes to wait for.
811    #[error(
812        "sni_preread_max_bytes = {sni_preread_max_bytes} on TCP listener {address} is below the \
813         minimum of {minimum} bytes (a full TLS record header): the preread shell could never \
814         read enough bytes to make progress. Raise sni_preread_max_bytes to >= {minimum}"
815    )]
816    SniPrereadMaxBytesTooSmall {
817        address: SocketAddr,
818        sni_preread_max_bytes: u32,
819        minimum: u32,
820    },
821    /// A backend `address` of the configuration file is neither a socket
822    /// address nor a `host:port` that resolves to at least one address
823    /// (sozu-proxy/sozu#347). Names are resolved once, when the file is
824    /// loaded at startup or by `sozu reload --file`.
825    #[error(
826        "cluster {cluster_id}: cannot resolve backend address {address:?}: {reason}. \
827         Expected an IP:port socket address or a resolvable host:port"
828    )]
829    BackendAddressResolution {
830        cluster_id: String,
831        address: String,
832        reason: String,
833    },
834}
835
836/// Config-facing mutual-TLS (client certificate authentication) mode, parsed
837/// from the `client_auth` key of an HTTPS listener's TOML section. Serializes as
838/// lowercase (`"none"` / `"optional"` / `"required"`) so operators write the
839/// documented values rather than the protobuf enum's `SCREAMING_SNAKE_CASE`.
840/// Maps to the wire [`ClientAuthMode`] in [`ListenerBuilder::to_tls`].
841#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
842#[serde(rename_all = "lowercase")]
843pub enum ClientAuthConfig {
844    #[default]
845    None,
846    Optional,
847    Required,
848}
849
850impl From<ClientAuthConfig> for ClientAuthMode {
851    fn from(value: ClientAuthConfig) -> Self {
852        match value {
853            ClientAuthConfig::None => ClientAuthMode::ClientAuthNone,
854            ClientAuthConfig::Optional => ClientAuthMode::ClientAuthOptional,
855            ClientAuthConfig::Required => ClientAuthMode::ClientAuthRequired,
856        }
857    }
858}
859
860/// An HTTP, HTTPS or TCP listener as parsed from the `Listeners` section in the toml
861#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
862#[serde(deny_unknown_fields)]
863pub struct ListenerBuilder {
864    pub address: SocketAddr,
865    /// Network interface the listening socket is bound to with
866    /// `SO_BINDTODEVICE` (e.g. `"wg0"`), for an address that is not known
867    /// ahead. Linux only: refused at config load on any other platform. Part
868    /// of the listener identity, so two listeners may share an `address` on
869    /// different interfaces.
870    pub interface: Option<String>,
871    pub protocol: Option<ListenerProtocol>,
872    pub public_address: Option<SocketAddr>,
873    pub answer_301: Option<String>,
874    pub answer_400: Option<String>,
875    pub answer_401: Option<String>,
876    pub answer_404: Option<String>,
877    pub answer_408: Option<String>,
878    pub answer_413: Option<String>,
879    /// RFC 9110 §15.5.20 — returned when the request's `:authority` / `Host`
880    /// host does not match the TLS SNI negotiated for this connection.
881    pub answer_421: Option<String>,
882    pub answer_502: Option<String>,
883    pub answer_503: Option<String>,
884    pub answer_504: Option<String>,
885    pub answer_507: Option<String>,
886    /// RFC 6585 §4 — emitted when a request would have reached a backend
887    /// but the per-(cluster, source-IP) connection limit is full. Honoured
888    /// like the other deprecated `answer_NNN` fields: copies into the
889    /// listener-level `answers` map at the matching status.
890    pub answer_429: Option<String>,
891    pub tls_versions: Option<Vec<TlsVersion>>,
892    pub cipher_list: Option<Vec<String>>,
893    pub cipher_suites: Option<Vec<String>>,
894    pub groups_list: Option<Vec<String>>,
895    pub expect_proxy: Option<bool>,
896    #[serde(default = "default_sticky_name")]
897    pub sticky_name: String,
898    pub certificate: Option<String>,
899    pub certificate_chain: Option<String>,
900    pub key: Option<String>,
901    /// maximum time of inactivity for a frontend socket
902    pub front_timeout: Option<u32>,
903    /// maximum time of inactivity for a backend socket
904    pub back_timeout: Option<u32>,
905    /// maximum time to connect to a backend server
906    pub connect_timeout: Option<u32>,
907    /// maximum time to receive a request since the connection started
908    pub request_timeout: Option<u32>,
909    /// A [Config] to pull defaults from
910    pub config: Option<Config>,
911    /// Number of TLS 1.3 tickets to send to a client when establishing a connection.
912    /// The ticket allow the client to resume a session. This protects the client
913    /// agains session tracking. Defaults to 4.
914    pub send_tls13_tickets: Option<u64>,
915    /// ALPN protocols to advertise during TLS handshake, in order of preference.
916    /// Valid values: "h2", "http/1.1". Defaults to ["h2", "http/1.1"].
917    pub alpn_protocols: Option<Vec<String>>,
918    /// H2 flood detection: max RST_STREAM frames per second window (CVE-2023-44487, CVE-2019-9514)
919    pub h2_max_rst_stream_per_window: Option<u32>,
920    /// H2 flood detection: max PING frames per second window (CVE-2019-9512)
921    pub h2_max_ping_per_window: Option<u32>,
922    /// H2 flood detection: max SETTINGS frames per second window (CVE-2019-9515)
923    pub h2_max_settings_per_window: Option<u32>,
924    /// H2 flood detection: max empty DATA frames per second window (CVE-2019-9518)
925    pub h2_max_empty_data_per_window: Option<u32>,
926    /// H2 flood detection: max unsolicited connection-level (stream 0)
927    /// WINDOW_UPDATE frames per sliding window — each DATA frame the proxy
928    /// sends entitles the peer to two that are not counted. Caps non-zero
929    /// stream-0 WINDOW_UPDATE floods that would otherwise stay under the
930    /// generic glitch counter. Default: 2000.
931    pub h2_max_window_update_stream0_per_window: Option<u32>,
932    /// Name of the correlation header Sozu injects into every request and
933    /// response. Default: `Sozu-Id`. Operators can rebrand (e.g. `X-Edge-Id`)
934    /// without touching code.
935    pub sozu_id_header: Option<String>,
936    /// H2 flood detection: max CONTINUATION frames per header block (CVE-2024-27316)
937    pub h2_max_continuation_frames: Option<u32>,
938    /// H2 flood detection: max accumulated protocol anomalies before
939    /// ENHANCE_YOUR_CALM. Default: 2000.
940    pub h2_max_glitch_count: Option<u32>,
941    /// H2 connection-level receive window size in bytes (RFC 9113 §6.9.2). Default: 16777216 (16 MiB).
942    pub h2_initial_connection_window: Option<u32>,
943    /// Maximum concurrent H2 streams (SETTINGS_MAX_CONCURRENT_STREAMS). Default: 100.
944    pub h2_max_concurrent_streams: Option<u32>,
945    /// Shrink threshold ratio for recycled stream slots. Default: 2.
946    pub h2_stream_shrink_ratio: Option<u32>,
947    /// H2 flood detection: floor of the connection-lifetime cap on RST_STREAM
948    /// frames received (CVE-2023-44487); trips once the count exceeds this
949    /// value and the resets received after a response started or on a closed
950    /// stream exceed the streams a backend answered. Default: 200000.
951    pub h2_max_rst_stream_lifetime: Option<u64>,
952    /// H2 flood detection: floor of the cap on "abusive" (pre-response-start)
953    /// RST_STREAM frames (Rapid Reset signature, CVE-2023-44487); trips once
954    /// the count exceeds this value and more than half of the
955    /// backend-routed streams were reset before their response.
956    /// Default: 1000.
957    pub h2_max_rst_stream_abusive_lifetime: Option<u64>,
958    /// H2 flood detection: floor of the cap on **server-emitted** RST_STREAM
959    /// frames the peer provoked (CVE-2025-8671 "MadeYouReset"); trips once the
960    /// count exceeds this value and more than half of the
961    /// backend-routed streams were reset before their response. Resets
962    /// Sōzu decides on its own and `NoError` resets are not counted.
963    /// Default: 10000.
964    pub h2_max_rst_stream_emitted_lifetime: Option<u64>,
965    /// H2 flood detection: maximum accumulated HPACK-decoded header list
966    /// size per request (SETTINGS_MAX_HEADER_LIST_SIZE, RFC 9113 §6.5.2).
967    /// Default: 65536.
968    pub h2_max_header_list_size: Option<u32>,
969    /// Maximum HPACK dynamic table size (SETTINGS_HEADER_TABLE_SIZE) accepted
970    /// from the peer. Caps the value the peer advertises in SETTINGS frames to
971    /// prevent unbounded HPACK encoder memory growth. Default: 65536.
972    pub h2_max_header_table_size: Option<u32>,
973    /// Maximum number of materialized header fields per request — HPACK fields
974    /// plus expanded cookie crumbs (RFC 9113 §8.2.3). Bounds the HPACK
975    /// indexed-reference header bomb. Default: 128.
976    pub h2_max_header_fields: Option<u32>,
977    /// H2 flood detection: share, in percent, of the pre-response RST_STREAM
978    /// cap's floor (`h2_max_rst_stream_abusive_lifetime`) at which a
979    /// connection refuses new client streams with `REFUSED_STREAM`
980    /// (retryable, RFC 9113 §8.7) and keeps serving its open streams. `0`
981    /// disables it; `100` or more never engages it before the cap.
982    /// Default: 50.
983    pub h2_stream_refusal_percent: Option<u32>,
984    /// Per-stream idle timeout, in seconds. An open H2 stream that makes no
985    /// forward progress for this duration is cancelled (RST_STREAM / CANCEL)
986    /// to defend against slow-multiplex Slowloris. Unset, it inherits
987    /// `back_timeout`, floored at 30 (so 30 with the default `back_timeout`);
988    /// an explicit value wins, `0` meaning 1.
989    pub h2_stream_idle_timeout_seconds: Option<u32>,
990    /// Maximum wall-clock seconds to wait for in-flight H2 streams after
991    /// `GOAWAY(NO_ERROR)` has been sent during soft-stop. Once the deadline
992    /// elapses the connection is forcibly closed with a final GOAWAY. Set to
993    /// `0` to wait for streams to finish (no forced close). Default: 5.
994    pub h2_graceful_shutdown_deadline_seconds: Option<u32>,
995    /// When true, every HTTP request served on this listener must have its
996    /// `:authority` / `Host` host exact-match the TLS SNI negotiated at
997    /// handshake (CWE-346 / CWE-444). Applies to HTTPS listeners only;
998    /// plaintext HTTP listeners never have an SNI to compare against.
999    /// Default: true.
1000    pub strict_sni_binding: Option<bool>,
1001    /// When true, this HTTPS listener only accepts HTTP/2 connections;
1002    /// clients that do not negotiate `h2` via TLS ALPN (including those
1003    /// that omit ALPN entirely) are dropped at handshake instead of
1004    /// silently downgrading to HTTP/1.1. Default: false.
1005    pub disable_http11: Option<bool>,
1006    /// When true, any client-supplied `X-Real-IP` header is stripped from
1007    /// requests before forwarding (anti-spoofing). Independently combinable
1008    /// with `send_x_real_ip`. Default: false.
1009    pub elide_x_real_ip: Option<bool>,
1010    /// When true, a proxy-generated `X-Real-IP` header carrying the
1011    /// connection peer IP (post-PROXY-v2 unwrap, i.e. the original client
1012    /// IP) is appended to every forwarded request. Independently combinable
1013    /// with `elide_x_real_ip`. Default: false.
1014    pub send_x_real_ip: Option<bool>,
1015    /// When true, this HTTP listener serves cleartext HTTP/2 (h2c) to a
1016    /// client whose first bytes are the HTTP/2 connection preface (RFC 9113
1017    /// §3.3, prior knowledge); every other connection stays HTTP/1.x. Applies
1018    /// to HTTP listeners only. Default: false.
1019    pub h2c_prior_knowledge: Option<bool>,
1020    /// Which forwarding header family this listener adds to every forwarded
1021    /// request (see [`ForwardedHeadersMode`]). Independent of
1022    /// `elide_x_real_ip` and `send_x_real_ip`. Default: `both`.
1023    pub forwarded_headers: Option<ForwardedHeadersMode>,
1024    /// Per-status HTTP answer templates at listener scope — the **global
1025    /// default** that fires whenever no cluster-level override matches.
1026    /// Map key is the HTTP status code (e.g. `"503"`); map value is
1027    /// either a filesystem path or an `inline:<body>` literal, see
1028    /// [`resolve_answer_source`]. Loaded into
1029    /// [`HttpListenerConfig::answers`] / [`HttpsListenerConfig::answers`]
1030    /// at build time via [`load_answers`].
1031    ///
1032    /// Cluster-level [`FileClusterConfig::answers`] entries override the
1033    /// matching status here for requests routed to that cluster.
1034    ///
1035    /// The deprecated per-status `answer_NNN` fields are still honoured
1036    /// for backwards compatibility but are equivalent to a one-line entry
1037    /// in this map; new configs should prefer `[listeners.answers]`.
1038    pub answers: Option<BTreeMap<String, String>>,
1039    /// Listener-default HSTS (RFC 6797) policy. When set, every HTTPS
1040    /// frontend on this listener that does not declare its own `[hsts]`
1041    /// block inherits this value. Per RFC 6797 §7.2 HSTS is rejected on
1042    /// HTTP listeners at config-load time; this field is only meaningful
1043    /// for HTTPS listeners. Defaults to `None` (no HSTS).
1044    pub hsts: Option<FileHstsConfig>,
1045    /// UDP listener only: maximum received datagram size, in bytes. Capped
1046    /// at the effective `buffer_size` at config-load (clamp + warn when
1047    /// larger). Defaults to [`DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE`].
1048    pub max_rx_datagram_size: Option<u32>,
1049    /// UDP listener only: maximum number of concurrent flows. `0` (the
1050    /// default) selects the runtime auto policy (~70% soft RLIMIT_NOFILE);
1051    /// a warning is emitted at config-load when an explicit value exceeds
1052    /// that bound.
1053    pub max_flows: Option<u32>,
1054    /// TCP listener only: time allowed to receive enough bytes of the TLS
1055    /// ClientHello to read the SNI extension, in seconds. Only meaningful
1056    /// when at least one SNI-scoped frontend targets this listener.
1057    /// Defaults to [`DEFAULT_SNI_PREREAD_TIMEOUT`].
1058    pub sni_preread_timeout: Option<u32>,
1059    /// TCP listener only: maximum number of bytes buffered while prereading
1060    /// the TLS ClientHello looking for the SNI extension. Only meaningful
1061    /// when at least one SNI-scoped frontend targets this listener; must
1062    /// not exceed the global `buffer_size` (validated at config-load).
1063    /// Defaults to [`DEFAULT_SNI_PREREAD_MAX_BYTES`].
1064    pub sni_preread_max_bytes: Option<u32>,
1065    /// HTTPS listener only: mutual TLS (client certificate authentication)
1066    /// mode (`none` / `optional` / `required`). Absent and `none` both keep the
1067    /// historical no-client-auth behavior.
1068    pub client_auth: Option<ClientAuthConfig>,
1069    /// HTTPS listener only: filesystem paths to PEM-encoded trusted CA
1070    /// certificates a client certificate must chain to. Loaded at
1071    /// config-materialization time (like `certificate`/`key`) and inlined as
1072    /// PEM into the resulting [`HttpsListenerConfig`].
1073    pub client_ca_certificates: Option<Vec<String>>,
1074    /// HTTPS listener only: filesystem paths to PEM-encoded CRLs used to
1075    /// reject revoked client certificates. Loaded alongside
1076    /// `client_ca_certificates`.
1077    pub client_ca_crls: Option<Vec<String>>,
1078}
1079
1080pub fn default_sticky_name() -> String {
1081    DEFAULT_STICKY_NAME.to_string()
1082}
1083
1084impl ListenerBuilder {
1085    /// starts building an HTTP Listener with config values for timeouts,
1086    /// or defaults if no config is provided
1087    pub fn new_http(address: SocketAddress) -> ListenerBuilder {
1088        Self::new(address, ListenerProtocol::Http)
1089    }
1090
1091    /// starts building an HTTPS Listener with config values for timeouts,
1092    /// or defaults if no config is provided
1093    pub fn new_tcp(address: SocketAddress) -> ListenerBuilder {
1094        Self::new(address, ListenerProtocol::Tcp)
1095    }
1096
1097    /// starts building a TCP Listener with config values for timeouts,
1098    /// or defaults if no config is provided
1099    pub fn new_https(address: SocketAddress) -> ListenerBuilder {
1100        Self::new(address, ListenerProtocol::Https)
1101    }
1102
1103    /// starts building a UDP Listener with config values for timeouts,
1104    /// or defaults if no config is provided
1105    pub fn new_udp(address: SocketAddress) -> ListenerBuilder {
1106        Self::new(address, ListenerProtocol::Udp)
1107    }
1108
1109    /// starts building a Listener
1110    fn new(address: SocketAddress, protocol: ListenerProtocol) -> ListenerBuilder {
1111        ListenerBuilder {
1112            address: address.into(),
1113            interface: None,
1114            answer_301: None,
1115            answer_401: None,
1116            answer_400: None,
1117            answer_404: None,
1118            answer_408: None,
1119            answer_413: None,
1120            answer_421: None,
1121            answer_502: None,
1122            answer_503: None,
1123            answer_504: None,
1124            answer_507: None,
1125            answer_429: None,
1126            back_timeout: None,
1127            certificate_chain: None,
1128            certificate: None,
1129            cipher_list: None,
1130            cipher_suites: None,
1131            groups_list: None,
1132            config: None,
1133            connect_timeout: None,
1134            expect_proxy: None,
1135            front_timeout: None,
1136            key: None,
1137            protocol: Some(protocol),
1138            public_address: None,
1139            request_timeout: None,
1140            send_tls13_tickets: None,
1141            sticky_name: DEFAULT_STICKY_NAME.to_string(),
1142            tls_versions: None,
1143            alpn_protocols: None,
1144            h2_max_rst_stream_per_window: None,
1145            h2_max_ping_per_window: None,
1146            h2_max_settings_per_window: None,
1147            h2_max_empty_data_per_window: None,
1148            h2_max_window_update_stream0_per_window: None,
1149            sozu_id_header: None,
1150            h2_max_continuation_frames: None,
1151            h2_max_glitch_count: None,
1152            h2_initial_connection_window: None,
1153            h2_max_concurrent_streams: None,
1154            h2_stream_shrink_ratio: None,
1155            h2_max_rst_stream_lifetime: None,
1156            h2_max_rst_stream_abusive_lifetime: None,
1157            h2_max_rst_stream_emitted_lifetime: None,
1158            h2_max_header_list_size: None,
1159            h2_max_header_table_size: None,
1160            h2_max_header_fields: None,
1161            h2_stream_refusal_percent: None,
1162            h2_stream_idle_timeout_seconds: None,
1163            h2_graceful_shutdown_deadline_seconds: None,
1164            strict_sni_binding: None,
1165            disable_http11: None,
1166            elide_x_real_ip: None,
1167            send_x_real_ip: None,
1168            h2c_prior_knowledge: None,
1169            forwarded_headers: None,
1170            answers: None,
1171            hsts: None,
1172            max_rx_datagram_size: None,
1173            max_flows: None,
1174            sni_preread_timeout: None,
1175            sni_preread_max_bytes: None,
1176            client_auth: None,
1177            client_ca_certificates: None,
1178            client_ca_crls: None,
1179        }
1180    }
1181
1182    /// Set the network interface the listener binds to; `None` clears it.
1183    pub fn with_interface<S>(&mut self, interface: Option<S>) -> &mut Self
1184    where
1185        S: ToString,
1186    {
1187        self.interface = interface.map(|interface| interface.to_string());
1188        self
1189    }
1190
1191    /// The validated `interface`: a valid interface name, on a platform that
1192    /// can bind a socket to an interface.
1193    fn validated_interface(&self) -> Result<Option<String>, ConfigError> {
1194        if let Some(interface) = &self.interface {
1195            validate_listener_interface(interface).map_err(|error| {
1196                ConfigError::ListenerInterface {
1197                    address: self.address,
1198                    error,
1199                }
1200            })?;
1201        }
1202        Ok(self.interface.clone())
1203    }
1204
1205    pub fn with_public_address(&mut self, public_address: Option<SocketAddr>) -> &mut Self {
1206        if let Some(address) = public_address {
1207            self.public_address = Some(address);
1208        }
1209        self
1210    }
1211
1212    pub fn with_answer_404_path<S>(&mut self, answer_404_path: Option<S>) -> &mut Self
1213    where
1214        S: ToString,
1215    {
1216        if let Some(path) = answer_404_path {
1217            self.answer_404 = Some(path.to_string());
1218        }
1219        self
1220    }
1221
1222    pub fn with_answer_503_path<S>(&mut self, answer_503_path: Option<S>) -> &mut Self
1223    where
1224        S: ToString,
1225    {
1226        if let Some(path) = answer_503_path {
1227            self.answer_503 = Some(path.to_string());
1228        }
1229        self
1230    }
1231
1232    pub fn with_tls_versions(&mut self, tls_versions: Vec<TlsVersion>) -> &mut Self {
1233        self.tls_versions = Some(tls_versions);
1234        self
1235    }
1236
1237    pub fn with_cipher_list(&mut self, cipher_list: Option<Vec<String>>) -> &mut Self {
1238        self.cipher_list = cipher_list;
1239        self
1240    }
1241
1242    pub fn with_cipher_suites(&mut self, cipher_suites: Option<Vec<String>>) -> &mut Self {
1243        self.cipher_suites = cipher_suites;
1244        self
1245    }
1246
1247    pub fn with_alpn_protocols(&mut self, alpn_protocols: Option<Vec<String>>) -> &mut Self {
1248        self.alpn_protocols = alpn_protocols;
1249        self
1250    }
1251
1252    /// When true, strip any client-supplied `X-Real-IP` header from
1253    /// forwarded requests (anti-spoofing). Default: false.
1254    pub fn with_elide_x_real_ip(&mut self, elide_x_real_ip: bool) -> &mut Self {
1255        self.elide_x_real_ip = Some(elide_x_real_ip);
1256        self
1257    }
1258
1259    /// When true, append a proxy-generated `X-Real-IP` header carrying the
1260    /// connection peer IP (post-PROXY-v2 unwrap) to every forwarded request.
1261    /// Default: false.
1262    pub fn with_send_x_real_ip(&mut self, send_x_real_ip: bool) -> &mut Self {
1263        self.send_x_real_ip = Some(send_x_real_ip);
1264        self
1265    }
1266
1267    /// When true, serve cleartext HTTP/2 to a client that opens with the
1268    /// HTTP/2 connection preface (prior knowledge). HTTP listeners only.
1269    /// Default: false.
1270    pub fn with_h2c_prior_knowledge(&mut self, h2c_prior_knowledge: bool) -> &mut Self {
1271        self.h2c_prior_knowledge = Some(h2c_prior_knowledge);
1272        self
1273    }
1274
1275    /// Which forwarding header family this listener adds to every forwarded
1276    /// request. Default: [`ForwardedHeadersMode::Both`].
1277    pub fn with_forwarded_headers(&mut self, forwarded_headers: ForwardedHeadersMode) -> &mut Self {
1278        self.forwarded_headers = Some(forwarded_headers);
1279        self
1280    }
1281
1282    pub fn with_expect_proxy(&mut self, expect_proxy: bool) -> &mut Self {
1283        self.expect_proxy = Some(expect_proxy);
1284        self
1285    }
1286
1287    pub fn with_sticky_name<S>(&mut self, sticky_name: Option<S>) -> &mut Self
1288    where
1289        S: ToString,
1290    {
1291        if let Some(name) = sticky_name {
1292            self.sticky_name = name.to_string();
1293        }
1294        self
1295    }
1296
1297    pub fn with_certificate<S>(&mut self, certificate: S) -> &mut Self
1298    where
1299        S: ToString,
1300    {
1301        self.certificate = Some(certificate.to_string());
1302        self
1303    }
1304
1305    pub fn with_certificate_chain(&mut self, certificate_chain: String) -> &mut Self {
1306        self.certificate = Some(certificate_chain);
1307        self
1308    }
1309
1310    pub fn with_key<S>(&mut self, key: String) -> &mut Self
1311    where
1312        S: ToString,
1313    {
1314        self.key = Some(key);
1315        self
1316    }
1317
1318    pub fn with_front_timeout(&mut self, front_timeout: Option<u32>) -> &mut Self {
1319        self.front_timeout = front_timeout;
1320        self
1321    }
1322
1323    pub fn with_back_timeout(&mut self, back_timeout: Option<u32>) -> &mut Self {
1324        self.back_timeout = back_timeout;
1325        self
1326    }
1327
1328    pub fn with_connect_timeout(&mut self, connect_timeout: Option<u32>) -> &mut Self {
1329        self.connect_timeout = connect_timeout;
1330        self
1331    }
1332
1333    pub fn with_request_timeout(&mut self, request_timeout: Option<u32>) -> &mut Self {
1334        self.request_timeout = request_timeout;
1335        self
1336    }
1337
1338    /// Register a single per-status answer template file path on this
1339    /// listener. The path is read off disk into the resulting listener's
1340    /// `answers` map at build time via [`load_answers`]. Repeated calls
1341    /// with the same status code overwrite the prior entry.
1342    pub fn with_answer<S, P>(&mut self, code: S, path: P) -> &mut Self
1343    where
1344        S: ToString,
1345        P: ToString,
1346    {
1347        self.answers
1348            .get_or_insert_with(BTreeMap::new)
1349            .insert(code.to_string(), path.to_string());
1350        self
1351    }
1352
1353    /// Replace the listener-scope answer-template path map. See
1354    /// [`Self::with_answer`].
1355    pub fn with_answers(&mut self, answers: BTreeMap<String, String>) -> &mut Self {
1356        self.answers = Some(answers);
1357        self
1358    }
1359
1360    /// Get the custom HTTP answers from the file system using the provided paths
1361    fn get_http_answers(&self) -> Result<Option<CustomHttpAnswers>, ConfigError> {
1362        let http_answers = CustomHttpAnswers {
1363            answer_301: read_http_answer_file(&self.answer_301)?,
1364            answer_400: read_http_answer_file(&self.answer_400)?,
1365            answer_401: read_http_answer_file(&self.answer_401)?,
1366            answer_404: read_http_answer_file(&self.answer_404)?,
1367            answer_408: read_http_answer_file(&self.answer_408)?,
1368            answer_413: read_http_answer_file(&self.answer_413)?,
1369            answer_421: read_http_answer_file(&self.answer_421)?,
1370            answer_502: read_http_answer_file(&self.answer_502)?,
1371            answer_503: read_http_answer_file(&self.answer_503)?,
1372            answer_504: read_http_answer_file(&self.answer_504)?,
1373            answer_507: read_http_answer_file(&self.answer_507)?,
1374            answer_429: read_http_answer_file(&self.answer_429)?,
1375        };
1376        Ok(Some(http_answers))
1377    }
1378
1379    /// Build the proto-side `answers` map for this listener.
1380    ///
1381    /// Merges, in order:
1382    /// 1. legacy per-status `answer_NNN` fields (if set), so legacy state
1383    ///    files round-trip into the new shape;
1384    /// 2. the explicit `[listeners.answers]` map (loaded via [`load_answers`]),
1385    ///    so new entries take precedence over legacy ones.
1386    fn get_listener_answers(&self) -> Result<BTreeMap<String, String>, ConfigError> {
1387        let mut out = BTreeMap::new();
1388
1389        // Pull bodies from the legacy per-status fields first so the new map
1390        // takes precedence on collision. Empty bodies are skipped to keep the
1391        // proto map minimal.
1392        macro_rules! merge_legacy {
1393            ($code:literal, $field:ident) => {
1394                if let Some(body) = read_http_answer_file(&self.$field)? {
1395                    out.insert($code.to_owned(), body);
1396                }
1397            };
1398        }
1399        merge_legacy!("301", answer_301);
1400        merge_legacy!("400", answer_400);
1401        merge_legacy!("401", answer_401);
1402        merge_legacy!("404", answer_404);
1403        merge_legacy!("408", answer_408);
1404        merge_legacy!("413", answer_413);
1405        merge_legacy!("421", answer_421);
1406        merge_legacy!("502", answer_502);
1407        merge_legacy!("503", answer_503);
1408        merge_legacy!("504", answer_504);
1409        merge_legacy!("507", answer_507);
1410        merge_legacy!("429", answer_429);
1411
1412        if let Some(map) = &self.answers {
1413            let loaded = load_answers(map)?;
1414            out.extend(loaded);
1415        }
1416        Ok(out)
1417    }
1418
1419    /// Assign the timeouts of the config to this listener, only if timeouts did not exist
1420    fn assign_config_timeouts(&mut self, config: &Config) {
1421        self.front_timeout = Some(self.front_timeout.unwrap_or(config.front_timeout));
1422        self.back_timeout = Some(self.back_timeout.unwrap_or(config.back_timeout));
1423        self.connect_timeout = Some(self.connect_timeout.unwrap_or(config.connect_timeout));
1424        self.request_timeout = Some(self.request_timeout.unwrap_or(config.request_timeout));
1425    }
1426
1427    /// Reject an H2 knob the runtime cannot honour, before it reaches a
1428    /// listener config (sozu-proxy/sozu#1418).
1429    ///
1430    /// `UpdateHttp(s)Listener` has refused these values since the knobs
1431    /// existed (`validate_h2_flood_knobs_http`/`_https`, `command/src/state.rs`)
1432    /// — the config file and `sozu ctl add listener` did not, so the same `0`
1433    /// an operator could not patch in, they could boot with. It reached
1434    /// `HttpListenerConfig` untouched, and `lib/src/http.rs::get_h2_flood_config`
1435    /// handed it to the flood detector, whose `check_flood` compares
1436    /// `count > threshold`: the first event the counter saw was already a
1437    /// violation. For `h2_max_header_list_size`/`h2_max_header_fields`, which
1438    /// double as the HPACK decode budget, that is every request on the
1439    /// listener; for the per-window and lifetime frame counters it is the
1440    /// first client that sends the frame they count. Rejecting here rather
1441    /// than clamping at use keeps the operator's mistake visible — the error
1442    /// names the key, the value and the minimum.
1443    ///
1444    /// The field set and the minimums are deliberately the same as
1445    /// `validate_h2_flood_knobs_http`'s: a value one door refuses must not get
1446    /// in through the other. `h2_graceful_shutdown_deadline_seconds = 0` stays
1447    /// allowed there and here — it means "wait forever".
1448    fn validate_h2_thresholds(&self) -> Result<(), ConfigError> {
1449        macro_rules! require_at_least {
1450            ($builder:ident, $field:ident, $minimum:literal) => {
1451                if let Some(value) = $builder.$field
1452                    && u64::from(value) < $minimum
1453                {
1454                    return Err(ConfigError::H2ThresholdBelowMinimum {
1455                        address: $builder.address.to_string(),
1456                        key: stringify!($field),
1457                        value: u64::from(value),
1458                        minimum: $minimum,
1459                    });
1460                }
1461            };
1462        }
1463        crate::for_each_h2_knob_floor!(require_at_least, self);
1464        Ok(())
1465    }
1466
1467    /// Reject mTLS fields on a non-HTTPS listener. Client auth is an
1468    /// HTTPS-termination control; the HTTP/TCP/UDP conversions have no field to
1469    /// carry it, so an operator who sets it there must get a typed error rather
1470    /// than a silently unauthenticated listener.
1471    fn reject_client_auth_fields(&self, listener_kind: &str) -> Result<(), ConfigError> {
1472        let has_mtls = self
1473            .client_auth
1474            .is_some_and(|m| m != ClientAuthConfig::None)
1475            || self
1476                .client_ca_certificates
1477                .as_ref()
1478                .is_some_and(|v| !v.is_empty())
1479            || self.client_ca_crls.as_ref().is_some_and(|v| !v.is_empty());
1480        if has_mtls {
1481            return Err(ConfigError::ClientAuthOnNonHttps(format!(
1482                "{} listener {}",
1483                listener_kind, self.address
1484            )));
1485        }
1486        Ok(())
1487    }
1488
1489    /// build an HTTP listener with config timeouts, using defaults if no config is provided
1490    pub fn to_http(&mut self, config: Option<&Config>) -> Result<HttpListenerConfig, ConfigError> {
1491        if self.protocol != Some(ListenerProtocol::Http) {
1492            return Err(ConfigError::WrongListenerProtocol {
1493                expected: ListenerProtocol::Http,
1494                found: self.protocol.to_owned(),
1495            });
1496        }
1497        self.reject_client_auth_fields("HTTP")?;
1498
1499        self.validate_h2_thresholds()?;
1500
1501        // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on
1502        // plaintext-HTTP responses. Reject an `[hsts]` block on an HTTP
1503        // listener at config-load — `HttpListenerConfig` has no `hsts`
1504        // field, so silently dropping the operator's intent would be a
1505        // worse failure mode than a typed error here.
1506        if self.hsts.is_some() {
1507            return Err(ConfigError::HstsOnPlainHttp(format!(
1508                "HTTP listener {}",
1509                self.address
1510            )));
1511        }
1512
1513        if let Some(config) = config {
1514            self.assign_config_timeouts(config);
1515        }
1516
1517        let http_answers = self.get_http_answers()?;
1518        let answers = self.get_listener_answers()?;
1519
1520        let interface = self.validated_interface()?;
1521        let configuration = HttpListenerConfig {
1522            address: self.address.into(),
1523            interface,
1524            public_address: self.public_address.map(|a| a.into()),
1525            expect_proxy: self.expect_proxy.unwrap_or(false),
1526            sticky_name: self.sticky_name.clone(),
1527            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1528            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1529            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1530            request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
1531            http_answers,
1532            answers,
1533            h2_max_rst_stream_per_window: self.h2_max_rst_stream_per_window,
1534            h2_max_ping_per_window: self.h2_max_ping_per_window,
1535            h2_max_settings_per_window: self.h2_max_settings_per_window,
1536            h2_max_empty_data_per_window: self.h2_max_empty_data_per_window,
1537            h2_max_window_update_stream0_per_window: self.h2_max_window_update_stream0_per_window,
1538            h2_max_continuation_frames: self.h2_max_continuation_frames,
1539            h2_max_glitch_count: self.h2_max_glitch_count,
1540            h2_initial_connection_window: self.h2_initial_connection_window,
1541            h2_max_concurrent_streams: self.h2_max_concurrent_streams,
1542            h2_stream_shrink_ratio: self.h2_stream_shrink_ratio,
1543            h2_max_rst_stream_lifetime: self.h2_max_rst_stream_lifetime,
1544            h2_max_rst_stream_abusive_lifetime: self.h2_max_rst_stream_abusive_lifetime,
1545            h2_max_rst_stream_emitted_lifetime: self.h2_max_rst_stream_emitted_lifetime,
1546            h2_max_header_list_size: self.h2_max_header_list_size,
1547            h2_max_header_table_size: self.h2_max_header_table_size,
1548            h2_max_header_fields: self.h2_max_header_fields,
1549            h2_stream_refusal_percent: self.h2_stream_refusal_percent,
1550            h2_stream_idle_timeout_seconds: self.h2_stream_idle_timeout_seconds,
1551            h2_graceful_shutdown_deadline_seconds: self.h2_graceful_shutdown_deadline_seconds,
1552            sozu_id_header: self.sozu_id_header.clone(),
1553            elide_x_real_ip: Some(self.elide_x_real_ip.unwrap_or(false)),
1554            send_x_real_ip: Some(self.send_x_real_ip.unwrap_or(false)),
1555            forwarded_headers: Some(ForwardedHeaders::from(
1556                self.forwarded_headers.unwrap_or_default(),
1557            ) as i32),
1558            // Passed through as-is: unset stays absent on the wire, so a
1559            // listener that never names the key serializes exactly as before.
1560            h2c_prior_knowledge: self.h2c_prior_knowledge,
1561            ..Default::default()
1562        };
1563
1564        // POST: the built listener binds exactly the address that was
1565        // requested — a listener whose address drifted here would bind the
1566        // wrong socket. (We reached this point only because the protocol guard
1567        // at entry confirmed this is an HTTP listener.)
1568        debug_assert_eq!(
1569            configuration.address,
1570            self.address.into(),
1571            "HTTP listener must bind the requested address"
1572        );
1573        Ok(configuration)
1574    }
1575
1576    /// build an HTTPS listener using defaults if no config or values were provided upstream
1577    pub fn to_tls(&mut self, config: Option<&Config>) -> Result<HttpsListenerConfig, ConfigError> {
1578        if self.protocol != Some(ListenerProtocol::Https) {
1579            return Err(ConfigError::WrongListenerProtocol {
1580                expected: ListenerProtocol::Https,
1581                found: self.protocol.to_owned(),
1582            });
1583        }
1584
1585        self.validate_h2_thresholds()?;
1586
1587        let default_cipher_list = DEFAULT_CIPHER_LIST.into_iter().map(String::from).collect();
1588
1589        let cipher_list = self.cipher_list.clone().unwrap_or(default_cipher_list);
1590
1591        let cipher_suites = self
1592            .cipher_suites
1593            .clone()
1594            .unwrap_or_else(|| DEFAULT_CIPHER_LIST.into_iter().map(String::from).collect());
1595
1596        let signature_algorithms: Vec<String> = DEFAULT_SIGNATURE_ALGORITHMS
1597            .into_iter()
1598            .map(String::from)
1599            .collect();
1600
1601        let groups_list = self
1602            .groups_list
1603            .clone()
1604            .unwrap_or_else(|| DEFAULT_GROUPS_LIST.into_iter().map(String::from).collect());
1605
1606        let alpn_protocols: Vec<String> = match &self.alpn_protocols {
1607            Some(protos) if !protos.is_empty() => {
1608                for proto in protos {
1609                    match proto.as_str() {
1610                        "h2" | "http/1.1" => {}
1611                        other => return Err(ConfigError::InvalidAlpnProtocol(other.to_owned())),
1612                    }
1613                }
1614                // disable_http11 + http/1.1 ALPN is a self-DoS — every
1615                // connection negotiates http/1.1 then is
1616                // immediately refused at `https.rs::upgrade_handshake`.
1617                // Reject the combination at config load.
1618                if self.disable_http11.unwrap_or(false) && protos.iter().any(|p| p == "http/1.1") {
1619                    return Err(ConfigError::DisableHttp11WithHttp11Alpn {
1620                        address: self.address.to_string(),
1621                    });
1622                }
1623                if !protos.iter().any(|p| p == "http/1.1") {
1624                    warn!(
1625                        "ALPN protocols do not include 'http/1.1'. Clients without H2 support will fail TLS negotiation."
1626                    );
1627                }
1628                // Deduplicate while preserving order
1629                let mut seen = std::collections::HashSet::new();
1630                protos
1631                    .iter()
1632                    .filter(|p| seen.insert(p.as_str()))
1633                    .cloned()
1634                    .collect()
1635            }
1636            _ => {
1637                // Same self-DoS check on the default ALPN list (which
1638                // contains "http/1.1") — `disable_http11 = true` with the
1639                // implicit default ALPN must also be rejected.
1640                if self.disable_http11.unwrap_or(false)
1641                    && DEFAULT_ALPN_PROTOCOLS.contains(&"http/1.1")
1642                {
1643                    return Err(ConfigError::DisableHttp11WithHttp11Alpn {
1644                        address: self.address.to_string(),
1645                    });
1646                }
1647                DEFAULT_ALPN_PROTOCOLS
1648                    .iter()
1649                    .map(|s| s.to_string())
1650                    .collect()
1651            }
1652        };
1653
1654        let versions = match self.tls_versions {
1655            None => vec![TlsVersion::TlsV12 as i32, TlsVersion::TlsV13 as i32],
1656            Some(ref v) => v.iter().map(|v| *v as i32).collect(),
1657        };
1658
1659        let key = self.key.as_ref().and_then(|path| {
1660            Config::load_file(path)
1661                .map_err(|e| {
1662                    error!("cannot load key at path '{}': {:?}", path, e);
1663                    e
1664                })
1665                .ok()
1666        });
1667        let certificate = self.certificate.as_ref().and_then(|path| {
1668            Config::load_file(path)
1669                .map_err(|e| {
1670                    error!("cannot load certificate at path '{}': {:?}", path, e);
1671                    e
1672                })
1673                .ok()
1674        });
1675        let certificate_chain = self
1676            .certificate_chain
1677            .as_ref()
1678            .and_then(|path| {
1679                Config::load_file(path)
1680                    .map_err(|e| {
1681                        error!("cannot load certificate chain at path '{}': {:?}", path, e);
1682                        e
1683                    })
1684                    .ok()
1685            })
1686            .map(split_certificate_chain)
1687            .unwrap_or_default();
1688
1689        // mTLS: load the trusted-CA and CRL PEM bundles from disk, inlining
1690        // their contents (like `certificate`/`key` above). Any unreadable path
1691        // aborts materialization: a dropped CA would weaken trust, and a
1692        // dropped CRL would silently disable revocation for certificates the
1693        // operator meant to reject. Failing closed keeps client auth honest.
1694        //
1695        // Files are read only for OPTIONAL/REQUIRED. In NONE the runtime
1696        // ignores CA/CRL data, so a stale path left in the config must not
1697        // block the whole configuration from loading. That tolerance needs an
1698        // explicit `client_auth = "none"`: CA or CRL files with no mode at all
1699        // most likely mean the operator expected client authentication, and
1700        // the default would start the listener without it.
1701        let has_client_auth_files = self
1702            .client_ca_certificates
1703            .as_ref()
1704            .is_some_and(|paths| !paths.is_empty())
1705            || self
1706                .client_ca_crls
1707                .as_ref()
1708                .is_some_and(|paths| !paths.is_empty());
1709        match self.client_auth {
1710            None if has_client_auth_files => {
1711                return Err(ConfigError::ClientAuthModeMissing(format!(
1712                    "HTTPS listener {}",
1713                    self.address
1714                )));
1715            }
1716            Some(ClientAuthConfig::None) if has_client_auth_files => {
1717                warn!(
1718                    "HTTPS listener {}: client_auth is \"none\", so its client_ca_certificates \
1719                     and client_ca_crls are ignored and clients are not authenticated",
1720                    self.address
1721                );
1722            }
1723            _ => {}
1724        }
1725        let mode = self.client_auth.unwrap_or_default();
1726        let client_auth = self
1727            .client_auth
1728            .map(|mode| ClientAuthMode::from(mode) as i32);
1729        let load_pem_paths = |paths: &Option<Vec<String>>| -> Result<Vec<String>, ConfigError> {
1730            if mode == ClientAuthConfig::None {
1731                return Ok(Vec::new());
1732            }
1733            paths
1734                .as_ref()
1735                .map(|list| list.iter().map(|path| Config::load_file(path)).collect())
1736                .unwrap_or_else(|| Ok(Vec::new()))
1737        };
1738        let client_ca_certificates = load_pem_paths(&self.client_ca_certificates)?;
1739        let client_ca_crls = load_pem_paths(&self.client_ca_crls)?;
1740
1741        let http_answers = self.get_http_answers()?;
1742        let answers = self.get_listener_answers()?;
1743
1744        if let Some(config) = config {
1745            self.assign_config_timeouts(config);
1746        }
1747
1748        let https_listener_config = HttpsListenerConfig {
1749            interface: self.validated_interface()?,
1750            address: self.address.into(),
1751            sticky_name: self.sticky_name.clone(),
1752            public_address: self.public_address.map(|a| a.into()),
1753            cipher_list,
1754            versions,
1755            expect_proxy: self.expect_proxy.unwrap_or(false),
1756            key,
1757            certificate,
1758            certificate_chain,
1759            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1760            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1761            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1762            request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
1763            cipher_suites,
1764            signature_algorithms,
1765            groups_list,
1766            active: false,
1767            send_tls13_tickets: self
1768                .send_tls13_tickets
1769                .unwrap_or(DEFAULT_SEND_TLS_13_TICKETS),
1770            http_answers,
1771            answers,
1772            alpn_protocols,
1773            h2_max_rst_stream_per_window: self.h2_max_rst_stream_per_window,
1774            h2_max_ping_per_window: self.h2_max_ping_per_window,
1775            h2_max_settings_per_window: self.h2_max_settings_per_window,
1776            h2_max_empty_data_per_window: self.h2_max_empty_data_per_window,
1777            h2_max_window_update_stream0_per_window: self.h2_max_window_update_stream0_per_window,
1778            h2_max_continuation_frames: self.h2_max_continuation_frames,
1779            h2_max_glitch_count: self.h2_max_glitch_count,
1780            h2_initial_connection_window: self.h2_initial_connection_window,
1781            h2_max_concurrent_streams: self.h2_max_concurrent_streams,
1782            h2_stream_shrink_ratio: self.h2_stream_shrink_ratio,
1783            h2_max_rst_stream_lifetime: self.h2_max_rst_stream_lifetime,
1784            h2_max_rst_stream_abusive_lifetime: self.h2_max_rst_stream_abusive_lifetime,
1785            h2_max_rst_stream_emitted_lifetime: self.h2_max_rst_stream_emitted_lifetime,
1786            h2_max_header_list_size: self.h2_max_header_list_size,
1787            h2_max_header_table_size: self.h2_max_header_table_size,
1788            h2_max_header_fields: self.h2_max_header_fields,
1789            h2_stream_refusal_percent: self.h2_stream_refusal_percent,
1790            strict_sni_binding: self.strict_sni_binding,
1791            disable_http11: self.disable_http11,
1792            h2_stream_idle_timeout_seconds: self.h2_stream_idle_timeout_seconds,
1793            h2_graceful_shutdown_deadline_seconds: self.h2_graceful_shutdown_deadline_seconds,
1794            sozu_id_header: self.sozu_id_header.clone(),
1795            elide_x_real_ip: Some(self.elide_x_real_ip.unwrap_or(false)),
1796            send_x_real_ip: Some(self.send_x_real_ip.unwrap_or(false)),
1797            forwarded_headers: Some(ForwardedHeaders::from(
1798                self.forwarded_headers.unwrap_or_default(),
1799            ) as i32),
1800            hsts: match self.hsts.as_ref() {
1801                Some(h) => Some(h.to_proto("listener")?),
1802                None => None,
1803            },
1804            client_auth,
1805            client_ca_certificates,
1806            client_ca_crls,
1807        };
1808
1809        // POST: the built listener binds the requested address and starts
1810        // inactive (the protocol guard at entry confirmed this is an HTTPS
1811        // listener).
1812        debug_assert_eq!(
1813            https_listener_config.address,
1814            self.address.into(),
1815            "HTTPS listener must bind the requested address"
1816        );
1817        debug_assert!(
1818            !https_listener_config.active,
1819            "a freshly built HTTPS listener must start inactive"
1820        );
1821        // POST: the resolved ALPN list is non-empty, contains only the two
1822        // protocols Sōzu speaks, and is duplicate-free — the validation/dedup
1823        // branches above are the sole producers, so a malformed list here would
1824        // mean an unvalidated path slipped through.
1825        debug_assert!(
1826            !https_listener_config.alpn_protocols.is_empty(),
1827            "resolved ALPN list must not be empty"
1828        );
1829        debug_assert!(
1830            https_listener_config
1831                .alpn_protocols
1832                .iter()
1833                .all(|p| p == "h2" || p == "http/1.1"),
1834            "resolved ALPN list must contain only h2 and http/1.1"
1835        );
1836        debug_assert!(
1837            {
1838                let mut seen = std::collections::HashSet::new();
1839                https_listener_config
1840                    .alpn_protocols
1841                    .iter()
1842                    .all(|p| seen.insert(p))
1843            },
1844            "resolved ALPN list must be duplicate-free"
1845        );
1846        // POST: disable_http11 + http/1.1 in ALPN is a self-DoS that the
1847        // validation above rejects — an Ok return must never carry that combo.
1848        debug_assert!(
1849            !(self.disable_http11.unwrap_or(false)
1850                && https_listener_config
1851                    .alpn_protocols
1852                    .iter()
1853                    .any(|p| p == "http/1.1")),
1854            "disable_http11 with http/1.1 in ALPN must have been rejected"
1855        );
1856        Ok(https_listener_config)
1857    }
1858
1859    /// build an HTTPS listener using defaults if no config or values were provided upstream
1860    pub fn to_tcp(&mut self, config: Option<&Config>) -> Result<TcpListenerConfig, ConfigError> {
1861        if self.protocol != Some(ListenerProtocol::Tcp) {
1862            return Err(ConfigError::WrongListenerProtocol {
1863                expected: ListenerProtocol::Tcp,
1864                found: self.protocol.to_owned(),
1865            });
1866        }
1867        self.reject_client_auth_fields("TCP")?;
1868
1869        if let Some(config) = config {
1870            self.assign_config_timeouts(config);
1871        }
1872
1873        let tcp_listener_config = TcpListenerConfig {
1874            interface: self.validated_interface()?,
1875            address: self.address.into(),
1876            public_address: self.public_address.map(|a| a.into()),
1877            expect_proxy: self.expect_proxy.unwrap_or(false),
1878            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1879            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1880            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1881            active: false,
1882            sni_preread_timeout: Some(
1883                self.sni_preread_timeout
1884                    .unwrap_or(DEFAULT_SNI_PREREAD_TIMEOUT),
1885            ),
1886            sni_preread_max_bytes: Some(
1887                self.sni_preread_max_bytes
1888                    .unwrap_or(DEFAULT_SNI_PREREAD_MAX_BYTES),
1889            ),
1890        };
1891
1892        // POST: the built listener binds exactly the requested address and is
1893        // created inactive (activation is a later, explicit step).
1894        debug_assert_eq!(
1895            tcp_listener_config.address,
1896            self.address.into(),
1897            "TCP listener must bind the requested address"
1898        );
1899        debug_assert!(
1900            !tcp_listener_config.active,
1901            "a freshly built TCP listener must start inactive"
1902        );
1903        Ok(tcp_listener_config)
1904    }
1905
1906    /// build a UDP listener. UDP has no `expect_proxy` / `connect_timeout`;
1907    /// flows are keyed by 4-tuple and torn down on idle.
1908    ///
1909    /// Timeouts: an unset `front_timeout` / `back_timeout` falls back to the
1910    /// UDP-specific defaults ([`DEFAULT_UDP_FRONT_TIMEOUT`] /
1911    /// [`DEFAULT_UDP_BACK_TIMEOUT`], both 30 s) — *not* the global HTTP/TCP
1912    /// `front_timeout` (60 s) / `back_timeout`. This keeps the effective
1913    /// default in lock-step with the CLI help and the proto
1914    /// `UdpListenerConfig` defaults (both 30 s). The global config is consulted
1915    /// only for `buffer_size` (the `max_rx_datagram_size` cap).
1916    ///
1917    /// Validation:
1918    /// * `max_rx_datagram_size` is clamped to the effective `buffer_size`
1919    ///   (with a warning) so a datagram can never exceed the pool buffer.
1920    /// * an explicit non-zero `max_flows` that exceeds ~70% of the soft
1921    ///   `RLIMIT_NOFILE` emits a warning (the per-flow connected sockets
1922    ///   would otherwise risk EMFILE).
1923    pub fn to_udp(&mut self, config: Option<&Config>) -> Result<UdpListenerConfig, ConfigError> {
1924        if self.protocol != Some(ListenerProtocol::Udp) {
1925            return Err(ConfigError::WrongListenerProtocol {
1926                expected: ListenerProtocol::Udp,
1927                found: self.protocol.to_owned(),
1928            });
1929        }
1930        self.reject_client_auth_fields("UDP")?;
1931
1932        let mut max_rx_datagram_size = self
1933            .max_rx_datagram_size
1934            .unwrap_or(DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE);
1935        let buffer_size = config.map(|c| c.buffer_size).unwrap_or(DEFAULT_BUFFER_SIZE);
1936        if u64::from(max_rx_datagram_size) > buffer_size {
1937            warn!(
1938                "UDP listener {}: max_rx_datagram_size = {} exceeds buffer_size = {}, clamping to buffer_size",
1939                self.address, max_rx_datagram_size, buffer_size
1940            );
1941            max_rx_datagram_size = buffer_size as u32;
1942        }
1943
1944        let max_flows = self.max_flows.unwrap_or(DEFAULT_UDP_MAX_FLOWS);
1945        if max_flows > 0
1946            && let Some(soft_limit) = soft_rlimit_nofile()
1947        {
1948            let advisory = soft_limit.saturating_mul(7) / 10;
1949            if u64::from(max_flows) > advisory {
1950                warn!(
1951                    "UDP listener {}: max_flows = {} exceeds ~70% of the soft RLIMIT_NOFILE ({}); \
1952                         per-flow connected sockets may hit EMFILE",
1953                    self.address, max_flows, advisory
1954                );
1955            }
1956        }
1957
1958        Ok(UdpListenerConfig {
1959            interface: self.validated_interface()?,
1960            address: self.address.into(),
1961            public_address: self.public_address.map(|a| a.into()),
1962            front_timeout: self.front_timeout.unwrap_or(DEFAULT_UDP_FRONT_TIMEOUT),
1963            back_timeout: self.back_timeout.unwrap_or(DEFAULT_UDP_BACK_TIMEOUT),
1964            max_rx_datagram_size,
1965            max_flows,
1966            active: false,
1967        })
1968    }
1969}
1970
1971/// Read the soft `RLIMIT_NOFILE` (max open file descriptors). Used as an
1972/// advisory ceiling for `max_flows` on UDP listeners. Returns `None` when
1973/// the limit cannot be read so callers skip the advisory check rather than
1974/// failing config-load.
1975fn soft_rlimit_nofile() -> Option<u64> {
1976    let mut limit = libc::rlimit {
1977        rlim_cur: 0,
1978        rlim_max: 0,
1979    };
1980    // SAFETY: `getrlimit` writes into the provided `rlimit` out-parameter and
1981    // does not retain the pointer. A non-zero return means failure, in which
1982    // case we ignore the (uninitialised-by-contract) value and return None.
1983    let rc = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) };
1984    if rc == 0 {
1985        // `rlim_cur` is `rlim_t`, which is `u64` on the Tier-1 targets sozu
1986        // builds for, so it already matches the `Option<u64>` return type.
1987        Some(limit.rlim_cur)
1988    } else {
1989        None
1990    }
1991}
1992
1993/// read a custom HTTP answer from a file
1994fn read_http_answer_file(path: &Option<String>) -> Result<Option<String>, ConfigError> {
1995    match path {
1996        Some(path) => {
1997            let mut content = String::new();
1998            let mut file = File::open(path).map_err(|io_error| ConfigError::FileOpen {
1999                path_to_open: path.to_owned(),
2000                io_error,
2001            })?;
2002
2003            file.read_to_string(&mut content)
2004                .map_err(|io_error| ConfigError::FileRead {
2005                    path_to_read: path.to_owned(),
2006                    io_error,
2007                })?;
2008
2009            Ok(Some(content))
2010        }
2011        None => Ok(None),
2012    }
2013}
2014
2015/// Resolve a single `answers` map entry into the literal template body
2016/// the proto layer expects.
2017///
2018/// The same resolution rule applies to entries at every layer:
2019/// * **Listener-level** `[listeners.<id>.answers]` — the global default
2020///   that fires whenever no more specific override matches.
2021/// * **Cluster-level** `[clusters.<id>.answers]` — overrides the
2022///   listener-level default for the matching status code on requests
2023///   routed to that cluster.
2024///
2025/// Two source forms are accepted:
2026/// * **Filesystem path** — the value starts with the `file://` URI
2027///   scheme. Everything after the prefix is treated as a path; the
2028///   path is opened and read into a string. Mirrors the on-disk
2029///   loading the per-status `read_http_answer_file` helper performs
2030///   for the deprecated `answer_301`..`answer_507` fields.
2031/// * **Inline literal** (default) — anything else. The value is taken
2032///   verbatim as the template body, including an empty string (a
2033///   0-byte response payload, typical with `Connection: close` and no
2034///   headers). The bare-string default keeps the common case — a
2035///   short canned response — typing-light; operators who need a file
2036///   say so explicitly with `file://`.
2037pub fn resolve_answer_source(value: &str) -> Result<String, ConfigError> {
2038    if let Some(path) = value.strip_prefix("file://") {
2039        let mut content = String::new();
2040        let mut file = File::open(path).map_err(|io_error| ConfigError::FileOpen {
2041            path_to_open: path.to_owned(),
2042            io_error,
2043        })?;
2044        file.read_to_string(&mut content)
2045            .map_err(|io_error| ConfigError::FileRead {
2046                path_to_read: path.to_owned(),
2047                io_error,
2048            })?;
2049        return Ok(content);
2050    }
2051    Ok(value.to_owned())
2052}
2053
2054/// Load every per-status template referenced by `answers`.
2055///
2056/// `answers` maps an HTTP status code (e.g. `"503"`) to either a
2057/// filesystem path or an `inline:<body>` literal — see
2058/// [`resolve_answer_source`] for the resolution rules. Each entry is
2059/// resolved into a body string and inserted into the returned map
2060/// under the same key, ready to be assigned to the proto-level
2061/// `answers` field on a [`HttpListenerConfig`] / [`HttpsListenerConfig`]
2062/// / [`Cluster`]. Empty values are skipped (treated as "preserve
2063/// current") so the caller can use them as a no-op stub in example
2064/// configs.
2065///
2066/// Errors map to the existing `ConfigError::FileOpen` /
2067/// `ConfigError::FileRead` variants so the operator gets the same
2068/// diagnostics whether the path comes from this map or from the
2069/// deprecated per-status `answer_301`..`answer_507` fields.
2070pub fn load_answers(
2071    answers: &BTreeMap<String, String>,
2072) -> Result<BTreeMap<String, String>, ConfigError> {
2073    let mut out = BTreeMap::new();
2074    for (code, value) in answers {
2075        if value.is_empty() {
2076            continue;
2077        }
2078        out.insert(code.to_owned(), resolve_answer_source(value)?);
2079    }
2080    // POST: the loaded map never invents a status code (every output key is an
2081    // input key) and never grows past the input — empty-valued entries are
2082    // skipped, so |out| <= |answers|.
2083    debug_assert!(
2084        out.len() <= answers.len(),
2085        "load_answers must not synthesize entries"
2086    );
2087    debug_assert!(
2088        out.keys().all(|k| answers.contains_key(k)),
2089        "every loaded status code must come from the input map"
2090    );
2091    Ok(out)
2092}
2093
2094/// Cardinality knob for metrics labels in the StatsD network drain.
2095///
2096/// Mirrors HAProxy's `process|frontend|backend|server` extra-counters opt-in.
2097/// Operators choose the lowest level that satisfies their dashboards so that
2098/// the keyspace stays bounded. Each level is a SUPERSET of the previous one:
2099///
2100/// - `process` — proxy-only counters (no listener, cluster, or backend label).
2101/// - `frontend` — adds per-listener (frontend) breakdown.
2102/// - `cluster` — adds per-cluster aggregation. **Default** (preserves the
2103///   pre-knob behaviour).
2104/// - `backend` — adds per-backend aggregation (cluster + backend, highest
2105///   cardinality).
2106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
2107#[serde(rename_all = "lowercase")]
2108#[derive(Default)]
2109pub enum MetricDetailLevel {
2110    Process,
2111    Frontend,
2112    #[default]
2113    Cluster,
2114    Backend,
2115}
2116
2117impl From<MetricDetailLevel> for MetricDetail {
2118    fn from(level: MetricDetailLevel) -> Self {
2119        match level {
2120            MetricDetailLevel::Process => MetricDetail::DetailProcess,
2121            MetricDetailLevel::Frontend => MetricDetail::DetailFrontend,
2122            MetricDetailLevel::Cluster => MetricDetail::DetailCluster,
2123            MetricDetailLevel::Backend => MetricDetail::DetailBackend,
2124        }
2125    }
2126}
2127
2128impl From<MetricDetail> for MetricDetailLevel {
2129    /// Reverse of [`From<MetricDetailLevel> for MetricDetail`] — used by the
2130    /// worker side to convert the protobuf wire enum back into the
2131    /// configuration enum before passing it to `sozu_lib::metrics::setup`.
2132    fn from(detail: MetricDetail) -> Self {
2133        match detail {
2134            MetricDetail::DetailProcess => MetricDetailLevel::Process,
2135            MetricDetail::DetailFrontend => MetricDetailLevel::Frontend,
2136            MetricDetail::DetailCluster => MetricDetailLevel::Cluster,
2137            MetricDetail::DetailBackend => MetricDetailLevel::Backend,
2138        }
2139    }
2140}
2141
2142/// The forwarding header family an HTTP or HTTPS listener adds to every
2143/// forwarded request, as spelled in the TOML configuration
2144/// (`forwarded_headers = "both"`). The wire form is [`ForwardedHeaders`].
2145///
2146/// - `both` — **default**, the historical behaviour: `X-Forwarded-For`,
2147///   `X-Forwarded-Proto`, `X-Forwarded-Port` and RFC 7239 `Forwarded`, a
2148///   client-supplied chain extended with this hop.
2149/// - `x_forwarded` — only the `X-Forwarded-*` family, handled as in `both`;
2150///   a client-supplied `Forwarded` passes through untouched.
2151/// - `rfc7239` — only `Forwarded` (RFC 7239 §4), a client-supplied chain
2152///   extended with this hop; a client-supplied `X-Forwarded-For`,
2153///   `X-Forwarded-Proto`, `X-Forwarded-Port` or `X-Forwarded-Host` is removed.
2154/// - `none` — no forwarding header is added and client-supplied ones pass
2155///   through untouched.
2156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
2157#[serde(rename_all = "snake_case")]
2158pub enum ForwardedHeadersMode {
2159    #[default]
2160    Both,
2161    XForwarded,
2162    Rfc7239,
2163    None,
2164}
2165
2166impl From<ForwardedHeadersMode> for ForwardedHeaders {
2167    fn from(mode: ForwardedHeadersMode) -> Self {
2168        match mode {
2169            ForwardedHeadersMode::Both => ForwardedHeaders::Both,
2170            ForwardedHeadersMode::XForwarded => ForwardedHeaders::XForwarded,
2171            ForwardedHeadersMode::Rfc7239 => ForwardedHeaders::Rfc7239,
2172            ForwardedHeadersMode::None => ForwardedHeaders::None,
2173        }
2174    }
2175}
2176
2177impl From<ForwardedHeaders> for ForwardedHeadersMode {
2178    fn from(headers: ForwardedHeaders) -> Self {
2179        match headers {
2180            ForwardedHeaders::Both => ForwardedHeadersMode::Both,
2181            ForwardedHeaders::XForwarded => ForwardedHeadersMode::XForwarded,
2182            ForwardedHeaders::Rfc7239 => ForwardedHeadersMode::Rfc7239,
2183            ForwardedHeaders::None => ForwardedHeadersMode::None,
2184        }
2185    }
2186}
2187
2188#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2189#[serde(deny_unknown_fields)]
2190pub struct MetricsConfig {
2191    pub address: SocketAddr,
2192    #[serde(default)]
2193    pub tagged_metrics: bool,
2194    #[serde(default)]
2195    pub prefix: Option<String>,
2196    /// Cardinality knob for label-aware metrics. Defaults to `cluster` to
2197    /// preserve historical behaviour. See [`MetricDetailLevel`].
2198    #[serde(default)]
2199    pub detail: MetricDetailLevel,
2200}
2201
2202#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2203#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2204#[serde(deny_unknown_fields)]
2205pub enum PathRuleType {
2206    Prefix,
2207    Regex,
2208    Equals,
2209    /// `REGEX_PATH`: a regex matched against the path up to its first `?`
2210    /// only, never the query string (sozu-proxy/sozu#1909).
2211    RegexPath,
2212}
2213
2214#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2215#[serde(deny_unknown_fields)]
2216pub struct FileClusterFrontendConfig {
2217    pub address: SocketAddr,
2218    pub hostname: Option<String>,
2219    /// TCP frontend only (sozu-proxy/sozu#1279): ALPN protocol names to
2220    /// match, read from the TLS ClientHello during the same preread that
2221    /// resolves `hostname` (mapped to the wire `sni` field for TCP
2222    /// frontends). Empty (the default) is the catch-all for this
2223    /// frontend's `hostname`/SNI on its listener. Rejected on HTTP/HTTPS
2224    /// frontends — ALPN there is negotiated by the listener's
2225    /// `alpn_protocols`, not per-frontend.
2226    #[serde(default)]
2227    pub alpn: Vec<String>,
2228    /// creates a path routing rule where the request URL path has to match this
2229    pub path: Option<String>,
2230    /// declares whether the path rule is Prefix (default), Regex, Equals, or
2231    /// RegexPath (a regex over the path alone, query string excluded)
2232    pub path_type: Option<PathRuleType>,
2233    pub method: Option<String>,
2234    pub certificate: Option<String>,
2235    pub key: Option<String>,
2236    pub certificate_chain: Option<String>,
2237    #[serde(default)]
2238    pub tls_versions: Vec<TlsVersion>,
2239    #[serde(default)]
2240    pub position: RulePosition,
2241    pub tags: Option<BTreeMap<String, String>>,
2242    /// Frontend-level redirect policy. Accepted values are `forward`
2243    /// (default — route to the backend), `permanent` (return 301 with the
2244    /// computed `Location`), or `unauthorized` (return 401 with
2245    /// `WWW-Authenticate: Basic realm=…`). Case-insensitive.
2246    pub redirect: Option<String>,
2247    /// Scheme used when emitting a permanent redirect's `Location`. Accepted
2248    /// values are `use-same` (default — preserve request scheme), `use-http`,
2249    /// `use-https`. Case-insensitive.
2250    pub redirect_scheme: Option<String>,
2251    /// Optional template applied to the emitted permanent-redirect response
2252    /// body. Supports the `%REDIRECT_LOCATION` and other variables
2253    /// documented in `doc/configure.md`.
2254    pub redirect_template: Option<String>,
2255    /// Rewrite host template. Supports `$HOST[n]` / `$PATH[n]` placeholders
2256    /// populated from regex captures collected during routing.
2257    pub rewrite_host: Option<String>,
2258    /// Rewrite path template. Same grammar as `rewrite_host`.
2259    pub rewrite_path: Option<String>,
2260    /// Optional literal port override on the rewritten URL.
2261    pub rewrite_port: Option<u32>,
2262    /// When true, requests routed through this frontend must carry a valid
2263    /// `Authorization: Basic <user:pass>` header whose hash matches one of
2264    /// the cluster's `authorized_hashes`. Default: false.
2265    pub required_auth: Option<bool>,
2266    /// Header mutations applied to requests and/or responses passing through
2267    /// this frontend. See [`HeaderEditConfig`] for the empty-value-deletes
2268    /// semantics (HAProxy `del-header` parity).
2269    pub headers: Option<Vec<HeaderEditConfig>>,
2270    /// Per-frontend HSTS (RFC 6797) policy. When set, overrides any
2271    /// listener-default HSTS for this frontend. Set `enabled = false`
2272    /// to suppress an inherited listener default. Per RFC 6797 §7.2,
2273    /// HSTS is rejected on plain-HTTP frontends at config-load time.
2274    pub hsts: Option<FileHstsConfig>,
2275    /// Header predicates the request must all satisfy for this frontend to
2276    /// match (sozu-proxy/sozu#1253). See [`HeaderPredicateConfig`].
2277    pub header_predicates: Option<Vec<HeaderPredicateConfig>>,
2278}
2279
2280/// A routing condition on one request header, as serialised under
2281/// `clusters.<id>.frontends.header_predicates` (sozu-proxy/sozu#1253).
2282/// Maps to the proto [`HeaderPredicate`] at request-build time.
2283///
2284/// `match` accepts `exact`, `regex`, or `present` (case-insensitive).
2285/// `value` is required for `exact` and `regex` and forbidden for
2286/// `present`.
2287#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2288#[serde(deny_unknown_fields)]
2289pub struct HeaderPredicateConfig {
2290    pub name: String,
2291    #[serde(rename = "match")]
2292    pub kind: String,
2293    pub value: Option<String>,
2294}
2295
2296/// A single header mutation as serialised under
2297/// `[[clusters.<id>.frontends.headers]]`. Maps to the proto [`Header`]
2298/// message at request-build time.
2299///
2300/// `position` accepts `request`, `response`, or `both` (case-insensitive).
2301/// An empty `value` deletes the header by name (HAProxy `del-header` parity).
2302#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2303#[serde(deny_unknown_fields)]
2304pub struct HeaderEditConfig {
2305    pub position: String,
2306    pub key: String,
2307    pub value: String,
2308}
2309
2310/// HSTS (HTTP Strict Transport Security, RFC 6797) policy as serialised
2311/// under `[https.listeners.default.hsts]` (listener default) or
2312/// `[clusters.<id>.frontends.hsts]` (per-frontend override).
2313///
2314/// `enabled` is REQUIRED whenever the block is present — its presence vs
2315/// absence disambiguates "preserve current" / "explicit disable" / "enable"
2316/// on hot-reconfig partial updates.
2317///
2318/// When `enabled = true` and `max_age` is omitted, sozu substitutes
2319/// [`DEFAULT_HSTS_MAX_AGE`] (1 year) at config-load time.
2320#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2321#[serde(deny_unknown_fields)]
2322pub struct FileHstsConfig {
2323    /// REQUIRED. `true` enables HSTS for this scope; `false` suppresses
2324    /// any inherited listener default (explicit-disable signal).
2325    pub enabled: Option<bool>,
2326    /// `Strict-Transport-Security: max-age=<seconds>`. Optional —
2327    /// defaults to [`DEFAULT_HSTS_MAX_AGE`] when `enabled = true`.
2328    /// `max_age = 0` is the RFC 6797 §11.4 kill switch and is allowed
2329    /// silently; `0 < max_age < 86400` warns at config-load.
2330    pub max_age: Option<u32>,
2331    /// Append `; includeSubDomains` to the rendered header.
2332    pub include_subdomains: Option<bool>,
2333    /// Append `; preload` to the rendered header. Opt-in only — see RFC
2334    /// 6797 §14.2 and <https://hstspreload.org/>.
2335    pub preload: Option<bool>,
2336    /// Operator opt-in to override any backend-supplied
2337    /// `Strict-Transport-Security` header. RFC 6797 §6.1 default
2338    /// behaviour is to PRESERVE the backend's value (sozu's edit uses
2339    /// `HeaderEditMode::SetIfAbsent`). Set this to `true` to harden a
2340    /// stale or weak upstream HSTS policy centrally — the materialiser
2341    /// then uses `HeaderEditMode::Set`, replacing any backend STS with
2342    /// sozu's rendered value.
2343    pub force_replace_backend: Option<bool>,
2344}
2345
2346impl FileHstsConfig {
2347    /// Validate and convert the file-level [`FileHstsConfig`] into the
2348    /// proto [`HstsConfig`]. `scope` is a human-readable string (e.g.
2349    /// "listener" or "frontend api/example.com") surfaced into errors
2350    /// and warnings so the operator can pinpoint the offending block.
2351    ///
2352    /// Validation:
2353    /// - `enabled` is required when any other field is set
2354    ///   (`HstsEnabledRequired`); the parser returns the typed error so
2355    ///   callers can fail fast.
2356    /// - `enabled = true && max_age = None` substitutes
2357    ///   [`DEFAULT_HSTS_MAX_AGE`].
2358    /// - `0 < max_age < 86400` warns (likely misconfig — sub-day max-age
2359    ///   is useful only for testing).
2360    /// - `preload = true` with `max_age < DEFAULT_HSTS_MAX_AGE` or
2361    ///   `include_subdomains != Some(true)` warns (the Chrome HSTS
2362    ///   preload list will reject the host).
2363    /// - `max_age = 0` is allowed silently (RFC 6797 §11.4 kill switch).
2364    pub fn to_proto(&self, scope: &str) -> Result<HstsConfig, ConfigError> {
2365        let enabled = match self.enabled {
2366            Some(v) => v,
2367            None => return Err(ConfigError::HstsEnabledRequired(scope.to_owned())),
2368        };
2369
2370        let max_age = match (enabled, self.max_age) {
2371            (true, None) => Some(DEFAULT_HSTS_MAX_AGE),
2372            (_, m) => m,
2373        };
2374
2375        if let Some(value) = max_age
2376            && value > 0
2377            && value < 86_400
2378        {
2379            warn!(
2380                "HSTS max_age = {}s on {} is below 1 day — this is almost certainly a \
2381                 misconfiguration. RFC 6797 §11.4 reserves max_age = 0 as the explicit kill \
2382                 switch.",
2383                value, scope
2384            );
2385        }
2386
2387        let include_subdomains = self.include_subdomains;
2388        let preload = self.preload;
2389
2390        if matches!(preload, Some(true)) {
2391            let max_age_value = max_age.unwrap_or(0);
2392            if max_age_value < DEFAULT_HSTS_MAX_AGE {
2393                warn!(
2394                    "HSTS preload = true on {} with max_age = {}s; the Chrome HSTS preload \
2395                     list requires max_age >= {} (https://hstspreload.org/).",
2396                    scope, max_age_value, DEFAULT_HSTS_MAX_AGE
2397                );
2398            }
2399            if include_subdomains != Some(true) {
2400                warn!(
2401                    "HSTS preload = true on {} without include_subdomains = true; the Chrome \
2402                     HSTS preload list requires includeSubDomains \
2403                     (https://hstspreload.org/).",
2404                    scope
2405                );
2406            }
2407        }
2408
2409        let config = HstsConfig {
2410            enabled: Some(enabled),
2411            max_age,
2412            include_subdomains,
2413            preload,
2414            force_replace_backend: self.force_replace_backend,
2415        };
2416
2417        // POST: a built HstsConfig always records an explicit `enabled` flag
2418        // (the `None` case errored above), and an enabled policy always carries
2419        // a max_age — defaulted to DEFAULT_HSTS_MAX_AGE when the operator left
2420        // it unset, so the worker never emits an `max-age`-less STS header.
2421        debug_assert_eq!(
2422            config.enabled,
2423            Some(enabled),
2424            "built HSTS config must record the resolved enabled flag"
2425        );
2426        debug_assert!(
2427            !enabled || config.max_age.is_some(),
2428            "an enabled HSTS policy must carry a max_age"
2429        );
2430        Ok(config)
2431    }
2432}
2433
2434impl FileClusterFrontendConfig {
2435    pub fn to_tcp_front(&self) -> Result<TcpFrontendConfig, ConfigError> {
2436        if self.path.is_some() {
2437            return Err(ConfigError::InvalidFrontendConfig(
2438                "path_prefix".to_string(),
2439            ));
2440        }
2441        if self.certificate.is_some() {
2442            return Err(ConfigError::InvalidFrontendConfig(
2443                "certificate".to_string(),
2444            ));
2445        }
2446        if self.certificate_chain.is_some() {
2447            return Err(ConfigError::InvalidFrontendConfig(
2448                "certificate_chain".to_string(),
2449            ));
2450        }
2451
2452        if self.header_predicates.is_some() {
2453            return Err(ConfigError::InvalidFrontendConfig(
2454                "header_predicates".to_string(),
2455            ));
2456        }
2457
2458        // `hostname` maps to the TCP frontend's `sni` (sozu-proxy/sozu#1279):
2459        // an SNI-scoped frontend reads the same TOML key HTTP frontends use
2460        // for their routing hostname, validated for the TCP-specific
2461        // exact-or-single-wildcard shape.
2462        let sni = match &self.hostname {
2463            Some(hostname) => Some(validate_sni_pattern(hostname)?),
2464            None => None,
2465        };
2466
2467        // `alpn` only ever gets consulted from within the SNI-scoped
2468        // preread route table (sozu-proxy/sozu#1279 hardening): a frontend
2469        // with no `hostname`/`sni` installs the worker's raw no-SNI
2470        // catch-all path instead, which never looks at `alpn` at all. Left
2471        // unchecked, this would load fine and then silently never enforce
2472        // the configured protocol list.
2473        if sni.is_none() && !self.alpn.is_empty() {
2474            return Err(ConfigError::AlpnWithoutSni {
2475                address: self.address,
2476            });
2477        }
2478
2479        let tcp_front = TcpFrontendConfig {
2480            address: self.address,
2481            tags: self.tags.clone(),
2482            sni,
2483            alpn: self.alpn.clone(),
2484            // Resolved against `known_addresses` in `populate_clusters`; a
2485            // bare `to_tcp_front` (no listener context) defaults to TCP.
2486            udp: false,
2487        };
2488        // POST: a TCP frontend binds exactly the requested address and carries
2489        // no HTTP-only attributes — the guards above reject path / certificate,
2490        // so an Ok return is a witness that none leaked through (an L7
2491        // attribute on an L4 frontend is a config-shape violation). `hostname`
2492        // is intentionally excluded from this witness: it is consumed above
2493        // into `sni`, not rejected.
2494        debug_assert_eq!(
2495            tcp_front.address, self.address,
2496            "TCP frontend must bind the requested address"
2497        );
2498        debug_assert!(
2499            self.path.is_none() && self.certificate.is_none() && self.certificate_chain.is_none(),
2500            "a built TCP frontend must carry no HTTP-only attributes"
2501        );
2502        debug_assert!(
2503            tcp_front.sni.is_some() || tcp_front.alpn.is_empty(),
2504            "a built TCP frontend without sni must never carry a non-empty alpn"
2505        );
2506        Ok(tcp_front)
2507    }
2508
2509    pub fn to_http_front(&self, _cluster_id: &str) -> Result<HttpFrontendConfig, ConfigError> {
2510        if !self.alpn.is_empty() {
2511            return Err(ConfigError::InvalidFrontendConfig("alpn".to_string()));
2512        }
2513
2514        let hostname = match &self.hostname {
2515            Some(hostname) => hostname.to_owned(),
2516            None => {
2517                return Err(ConfigError::Missing(MissingKind::Field(
2518                    "hostname".to_string(),
2519                )));
2520            }
2521        };
2522
2523        let key_opt = match self.key.as_ref() {
2524            None => None,
2525            Some(path) => {
2526                let key = Config::load_file(path)?;
2527                Some(key)
2528            }
2529        };
2530
2531        let certificate_opt = match self.certificate.as_ref() {
2532            None => None,
2533            Some(path) => {
2534                let certificate = Config::load_file(path)?;
2535                Some(certificate)
2536            }
2537        };
2538
2539        let certificate_chain = match self.certificate_chain.as_ref() {
2540            None => None,
2541            Some(path) => {
2542                let certificate_chain = Config::load_file(path)?;
2543                Some(split_certificate_chain(certificate_chain))
2544            }
2545        };
2546
2547        let path = match (self.path.as_ref(), self.path_type.as_ref()) {
2548            (None, _) => PathRule::prefix("".to_string()),
2549            (Some(s), Some(PathRuleType::Prefix)) => PathRule::prefix(s.to_string()),
2550            (Some(s), Some(PathRuleType::Regex)) => PathRule::regex(s.to_string()),
2551            (Some(s), Some(PathRuleType::Equals)) => PathRule::equals(s.to_string()),
2552            (Some(s), Some(PathRuleType::RegexPath)) => PathRule::regex_path(s.to_string()),
2553            (Some(s), None) => PathRule::prefix(s.clone()),
2554        };
2555
2556        let redirect = match self.redirect.as_deref() {
2557            Some(v) => Some(parse_redirect_policy(v)?),
2558            None => None,
2559        };
2560        let redirect_scheme = match self.redirect_scheme.as_deref() {
2561            Some(v) => Some(parse_redirect_scheme(v)?),
2562            None => None,
2563        };
2564
2565        let headers = match self.headers.as_ref() {
2566            Some(entries) => {
2567                let mut out = Vec::with_capacity(entries.len());
2568                for (index, entry) in entries.iter().enumerate() {
2569                    out.push(parse_header_edit(index, entry)?);
2570                }
2571                out
2572            }
2573            None => Vec::new(),
2574        };
2575
2576        // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on
2577        // plaintext-HTTP responses. A frontend without a key+certificate
2578        // pair generates `RequestType::AddHttpFrontend` in
2579        // `HttpFrontendConfig::generate_requests`, so HSTS configured
2580        // there would silently target an HTTP frontend. Reject at
2581        // config-load before the cert-presence branch can consume it.
2582        let header_predicates = match self.header_predicates.as_ref() {
2583            Some(entries) => entries
2584                .iter()
2585                .enumerate()
2586                .map(|(index, entry)| parse_header_predicate(index, entry))
2587                .collect::<Result<Vec<_>, _>>()?,
2588            None => Vec::new(),
2589        };
2590
2591        let frontend_serves_https = key_opt.is_some() && certificate_opt.is_some();
2592        let hsts = match self.hsts.as_ref() {
2593            Some(h) => {
2594                if !frontend_serves_https {
2595                    return Err(ConfigError::HstsOnPlainHttp(format!(
2596                        "frontend {_cluster_id}/{hostname}"
2597                    )));
2598                }
2599                Some(h.to_proto(&format!("frontend {_cluster_id}/{hostname}"))?)
2600            }
2601            None => None,
2602        };
2603
2604        Ok(HttpFrontendConfig {
2605            address: self.address,
2606            hostname,
2607            certificate: certificate_opt,
2608            key: key_opt,
2609            certificate_chain,
2610            tls_versions: self.tls_versions.clone(),
2611            position: self.position,
2612            path,
2613            method: self.method.clone(),
2614            tags: self.tags.clone(),
2615            redirect,
2616            redirect_scheme,
2617            redirect_template: self.redirect_template.clone(),
2618            rewrite_host: self.rewrite_host.clone(),
2619            rewrite_path: self.rewrite_path.clone(),
2620            rewrite_port: self.rewrite_port,
2621            required_auth: self.required_auth,
2622            headers,
2623            hsts,
2624            header_predicates,
2625        })
2626    }
2627}
2628
2629/// Validates and normalizes a TCP frontend's SNI pattern
2630/// (sozu-proxy/sozu#1279): either an exact hostname or a single leading
2631/// `*.` wildcard label. Rejects an embedded `*` anywhere else (so
2632/// `*.*.example.com` and `foo.*.com` are both invalid), a bare `*`, any
2633/// empty label (leading/trailing/consecutive dots), and any non-ASCII
2634/// character ([`ConfigError::NonAsciiSniPattern`]) — on-wire SNI is always
2635/// an ASCII A-label (RFC 6066 §3 / IDNA), so a Unicode U-label would load
2636/// fine but never match at runtime, a silent routing failure. Full IDNA/
2637/// punycode transformation at config-load is intentionally out of scope
2638/// (no `idna` dependency in this crate); operators write the A-label form
2639/// directly, consistent with what the HTTP router produces via
2640/// `idna::domain_to_ascii` in `lib/src/router/mod.rs`. ASCII-lowercases
2641/// the accepted pattern for case-insensitive comparison.
2642///
2643/// Public: this is the SINGLE SNI shape validator shared by TOML config
2644/// load (this module) and the worker's request boundary
2645/// (`TcpListener::validate_new_tcp_front` in `lib/src/tcp.rs`), which an
2646/// `AddTcpFrontend` sent directly over the command socket, or a
2647/// `LoadState` replay, can reach without ever going through config-load —
2648/// both call sites must reject the identical set of malformed shapes.
2649pub fn validate_sni_pattern(sni: &str) -> Result<String, ConfigError> {
2650    let invalid = || ConfigError::InvalidSniPattern {
2651        sni: sni.to_string(),
2652    };
2653
2654    if sni.is_empty() {
2655        return Err(invalid());
2656    }
2657
2658    if !sni.is_ascii() {
2659        return Err(ConfigError::NonAsciiSniPattern {
2660            sni: sni.to_string(),
2661        });
2662    }
2663
2664    // Only a single leading "*." wildcard label is accepted; strip it (if
2665    // present) before checking the remainder is otherwise plain and
2666    // non-empty.
2667    let remainder = sni.strip_prefix("*.").unwrap_or(sni);
2668
2669    // `/` is never valid in a hostname — and the SNI route table is a
2670    // `pattern_trie::TrieNode`, whose insert treats a leftmost label wrapped
2671    // in `/.../` as a REGEX segment; letting one through would silently
2672    // widen routing beyond the documented "exact host or one leading `*.`"
2673    // contract. Checking `remainder` covers the whole pattern: the only
2674    // stripped prefix is the literal `*.`, which cannot contain `/`.
2675    if remainder.is_empty() || remainder.contains('*') || remainder.contains('/') {
2676        return Err(invalid());
2677    }
2678    if remainder.split('.').any(|label| label.is_empty()) {
2679        return Err(invalid());
2680    }
2681
2682    let normalized = sni.to_ascii_lowercase();
2683    // POST: the normalized pattern carries exactly one '*' (the leading
2684    // wildcard marker) or none at all — never more, since the checks above
2685    // reject any '*' in the remainder — and is pure ASCII, since non-ASCII
2686    // input was rejected before normalization (a non-ASCII pattern can
2687    // never match the ASCII A-label SNI on the wire).
2688    debug_assert!(
2689        normalized.matches('*').count() <= 1,
2690        "a validated SNI pattern must carry at most one wildcard marker"
2691    );
2692    debug_assert!(
2693        normalized.is_ascii(),
2694        "a validated SNI pattern must be pure ASCII"
2695    );
2696    Ok(normalized)
2697}
2698
2699/// Parse a `redirect` TOML value (case-insensitive) into the proto enum.
2700pub(crate) fn parse_redirect_policy(value: &str) -> Result<RedirectPolicy, ConfigError> {
2701    match value.to_ascii_lowercase().as_str() {
2702        "forward" => Ok(RedirectPolicy::Forward),
2703        "permanent" => Ok(RedirectPolicy::Permanent),
2704        "unauthorized" => Ok(RedirectPolicy::Unauthorized),
2705        "silent_close" => Ok(RedirectPolicy::SilentClose),
2706        _ => Err(ConfigError::InvalidRedirectPolicy(value.to_owned())),
2707    }
2708}
2709
2710/// Parse a `redirect_scheme` TOML value (case-insensitive) into the proto enum.
2711pub(crate) fn parse_redirect_scheme(value: &str) -> Result<RedirectScheme, ConfigError> {
2712    match value.to_ascii_lowercase().as_str() {
2713        "use-same" | "use_same" => Ok(RedirectScheme::UseSame),
2714        "use-http" | "use_http" => Ok(RedirectScheme::UseHttp),
2715        "use-https" | "use_https" => Ok(RedirectScheme::UseHttps),
2716        _ => Err(ConfigError::InvalidRedirectScheme(value.to_owned())),
2717    }
2718}
2719
2720/// Parse a `[[clusters.<id>.frontends.headers]]` entry into the proto
2721/// [`Header`] message. `index` is the zero-based position of `entry` in
2722/// the source array — surfaced into the error so a multi-entry config
2723/// pinpoints the bad row instead of just naming the unknown position.
2724/// An empty `value` is the HAProxy `del-header` parity (deletes the
2725/// header by name); the proto carries the empty string verbatim.
2726pub(crate) fn parse_header_edit(
2727    index: usize,
2728    entry: &HeaderEditConfig,
2729) -> Result<Header, ConfigError> {
2730    let position = match entry.position.to_ascii_lowercase().as_str() {
2731        "request" => HeaderPosition::Request,
2732        "response" => HeaderPosition::Response,
2733        "both" => HeaderPosition::Both,
2734        _ => {
2735            return Err(ConfigError::InvalidHeaderPosition {
2736                index,
2737                position: entry.position.clone(),
2738            });
2739        }
2740    };
2741    if !header_name_is_valid_token(entry.key.as_bytes()) {
2742        return Err(ConfigError::InvalidHeaderBytes {
2743            index,
2744            field: "key",
2745        });
2746    }
2747    if header_value_contains_forbidden_controls(entry.value.as_bytes()) {
2748        return Err(ConfigError::InvalidHeaderBytes {
2749            index,
2750            field: "value",
2751        });
2752    }
2753    let header = Header {
2754        position: position as i32,
2755        key: entry.key.clone(),
2756        val: entry.value.clone(),
2757    };
2758    // POST: a Header that escapes this function carries a key that is a valid
2759    // RFC 9110 token and a value free of the forbidden control bytes — the two
2760    // guards above are the sole gate, so an emitted Header can never inject a
2761    // CRLF or a bad token onto the H1 wire (mirrors the runtime filter in
2762    // mux/converter.rs).
2763    debug_assert!(
2764        header_name_is_valid_token(header.key.as_bytes()),
2765        "an emitted header key must be a valid token"
2766    );
2767    debug_assert!(
2768        !header_value_contains_forbidden_controls(header.val.as_bytes()),
2769        "an emitted header value must be free of forbidden control bytes"
2770    );
2771    Ok(header)
2772}
2773
2774/// Parse one `header_predicates` entry of a frontend into the proto
2775/// [`HeaderPredicate`] (sozu-proxy/sozu#1253), then hold it to
2776/// [`validate_header_predicate`].
2777pub(crate) fn parse_header_predicate(
2778    index: usize,
2779    entry: &HeaderPredicateConfig,
2780) -> Result<HeaderPredicate, ConfigError> {
2781    let kind = match entry.kind.to_ascii_lowercase().as_str() {
2782        "exact" => HeaderMatchKind::Exact,
2783        "regex" => HeaderMatchKind::Regex,
2784        "present" => HeaderMatchKind::Present,
2785        _ => {
2786            return Err(ConfigError::InvalidHeaderPredicate {
2787                index,
2788                reason: "match must be \"exact\", \"regex\" or \"present\"",
2789            });
2790        }
2791    };
2792    let predicate = HeaderPredicate {
2793        name: entry.name.to_owned(),
2794        kind: kind as i32,
2795        value: entry.value.to_owned(),
2796    };
2797    validate_header_predicate(&predicate)
2798        .map_err(|reason| ConfigError::InvalidHeaderPredicate { index, reason })?;
2799    Ok(predicate)
2800}
2801
2802/// Validate one frontend header predicate (sozu-proxy/sozu#1253).
2803///
2804/// The name must be an RFC 9110 `token`, and neither `Host` nor `Cookie`:
2805/// like an `affinity_header` ([`validate_affinity_key`]), a predicate on
2806/// either could never find its header among the request's — H1 folds `Host`
2807/// into the request authority (H2 sends `:authority`) and both parsers move
2808/// cookies into a jar — so it would silently never match. `exact` and `regex`
2809/// need a `value`, `present` refuses one, and an `exact` value carrying a
2810/// control byte no parsed header value can hold is refused rather than left
2811/// to never match. A `regex` value's grammar is the router's to check, where
2812/// it is compiled.
2813///
2814/// Called at config load and by the worker's router on every frontend
2815/// addition, which the main process also runs before accepting one
2816/// (`validate_frontend_request` in `bin/src/command/requests.rs`), so a TOML
2817/// load, a saved state and a direct API request are held to the same rule.
2818pub fn validate_header_predicate(predicate: &HeaderPredicate) -> Result<(), &'static str> {
2819    let name = predicate.name.as_str();
2820    if !header_name_is_valid_token(name.as_bytes()) {
2821        return Err("name must be a non-empty RFC 9110 token (a header field name)");
2822    }
2823    if name.eq_ignore_ascii_case("host") {
2824        return Err(
2825            "name cannot be Host: the request authority is not a header Sōzu keeps \
2826             (H1 folds Host into it, H2 sends :authority); route on the hostname instead",
2827        );
2828    }
2829    if name.eq_ignore_ascii_case("cookie") {
2830        return Err("name cannot be Cookie: cookies are parsed into a jar, not kept as a header");
2831    }
2832    match (
2833        HeaderMatchKind::try_from(predicate.kind),
2834        predicate.value.as_deref(),
2835    ) {
2836        (Err(_), _) => Err("unknown match kind"),
2837        (Ok(HeaderMatchKind::Present), Some(_)) => Err("a present predicate takes no value"),
2838        (Ok(HeaderMatchKind::Present), None) => Ok(()),
2839        (Ok(HeaderMatchKind::Exact | HeaderMatchKind::Regex), None) => {
2840            Err("an exact or regex predicate requires a value")
2841        }
2842        (Ok(HeaderMatchKind::Exact), Some(value))
2843            if header_value_contains_forbidden_controls(value.as_bytes()) =>
2844        {
2845            Err("an exact value cannot carry a control character no header value can hold")
2846        }
2847        (Ok(HeaderMatchKind::Exact | HeaderMatchKind::Regex), Some(_)) => Ok(()),
2848    }
2849}
2850
2851/// Field names follow the RFC 9110 §5.1 `token` grammar: non-empty,
2852/// composed of `tchar` bytes (alphanumeric plus a closed punctuation
2853/// list). HTAB and SP are NOT tchar — they belong to field-value
2854/// grammar and must be rejected in the name. Reusing the more
2855/// permissive value-side filter would let `Host\t` slip through and
2856/// produce an invalid header line on the H1 wire (security review
2857/// LISA-002 follow-up).
2858pub(crate) fn header_name_is_valid_token(bytes: &[u8]) -> bool {
2859    if bytes.is_empty() {
2860        return false;
2861    }
2862    bytes.iter().all(|&b| is_tchar(b))
2863}
2864
2865/// `tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
2866/// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA` per RFC 9110 §5.6.2.
2867fn is_tchar(b: u8) -> bool {
2868    b.is_ascii_alphanumeric()
2869        || matches!(
2870            b,
2871            b'!' | b'#'
2872                | b'$'
2873                | b'%'
2874                | b'&'
2875                | b'\''
2876                | b'*'
2877                | b'+'
2878                | b'-'
2879                | b'.'
2880                | b'^'
2881                | b'_'
2882                | b'`'
2883                | b'|'
2884                | b'~'
2885        )
2886}
2887
2888/// Reject any byte that would let a header injection escape the value
2889/// block on the wire (RFC 9110 §5.5 / RFC 9113 §8.2.1):
2890/// `\0..=\x08`, `\x0A..=\x1F`, and `\x7F` — the entire C0 control set
2891/// minus horizontal tab `\x09`, which RFC 9110 explicitly permits in
2892/// field values. Mirrors the runtime filter at
2893/// `lib/src/protocol/mux/converter.rs::call` so config-load and runtime
2894/// agree on which header values may travel.
2895pub(crate) fn header_value_contains_forbidden_controls(bytes: &[u8]) -> bool {
2896    bytes
2897        .iter()
2898        .any(|&b| matches!(b, 0x00..=0x08 | 0x0A..=0x1F | 0x7F))
2899}
2900
2901#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2902#[serde(deny_unknown_fields, rename_all = "lowercase")]
2903pub enum ListenerProtocol {
2904    Http,
2905    Https,
2906    Tcp,
2907    Udp,
2908}
2909
2910#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2911#[serde(deny_unknown_fields, rename_all = "lowercase")]
2912pub enum FileClusterProtocolConfig {
2913    Http,
2914    Tcp,
2915}
2916
2917fn default_health_check_interval() -> u32 {
2918    10
2919}
2920fn default_health_check_timeout() -> u32 {
2921    5
2922}
2923fn default_health_check_threshold() -> u32 {
2924    3
2925}
2926fn default_health_check_uri() -> String {
2927    "/".to_owned()
2928}
2929
2930#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2931#[serde(deny_unknown_fields)]
2932pub struct FileHealthCheckConfig {
2933    /// Request target of the HTTP probe. Defaults to `/`; ignored in `TCP`
2934    /// mode.
2935    #[serde(default = "default_health_check_uri")]
2936    pub uri: String,
2937    #[serde(default = "default_health_check_interval")]
2938    pub interval: u32,
2939    #[serde(default = "default_health_check_timeout")]
2940    pub timeout: u32,
2941    #[serde(default = "default_health_check_threshold")]
2942    pub healthy_threshold: u32,
2943    #[serde(default = "default_health_check_threshold")]
2944    pub unhealthy_threshold: u32,
2945    #[serde(default)]
2946    pub expected_status: u32,
2947    /// Probe mode, parsed in SCREAMING_SNAKE_CASE: `"HTTP"` (the default)
2948    /// or `"TCP"`.
2949    #[serde(default)]
2950    pub mode: Option<HealthCheckMode>,
2951    /// HTTP mode: statuses that count as healthy, each one of `"200"`,
2952    /// `"200-399"`, `"2xx"` or `"any"` (see [`parse_http_status_range`]).
2953    #[serde(default)]
2954    pub accepted_statuses: Vec<String>,
2955    /// Port the probe dials on the backend's IP address, in `1..=65535`.
2956    /// Unset probes the backend's data port.
2957    #[serde(default)]
2958    pub port: Option<u32>,
2959    /// Extra request header fields of the HTTP probe, as an ordered array of
2960    /// `{ name, value_file }` tables. A value is never written in the
2961    /// configuration: `value_file` names the file the value is read from when
2962    /// the probe is set up, and parsing never opens it.
2963    #[serde(default)]
2964    pub headers: Vec<FileHealthCheckHeader>,
2965}
2966
2967/// One `[[clusters.<id>.health_check.headers]]` entry: a header name and the
2968/// file holding its value. Also the reference kept by [`HttpClusterConfig`]
2969/// and [`TcpClusterConfig`], so a path is stored exactly as written; resolving
2970/// a relative one is left to the code that reads the file.
2971#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2972#[serde(deny_unknown_fields)]
2973pub struct FileHealthCheckHeader {
2974    pub name: String,
2975    pub value_file: String,
2976}
2977
2978impl std::fmt::Debug for FileHealthCheckHeader {
2979    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2980        f.debug_struct("FileHealthCheckHeader")
2981            .field("name_len", &self.name.len())
2982            .field("value_file", &"<redacted>")
2983            .finish_non_exhaustive()
2984    }
2985}
2986
2987impl FileHealthCheckConfig {
2988    /// Build the proto config, parsing `accepted_statuses` and running
2989    /// [`validate_health_check_config`] so a bad block is refused at load
2990    /// time with the cluster it belongs to.
2991    pub fn to_proto(&self) -> Result<HealthCheckConfig, &'static str> {
2992        // A proto config names its headers without a value, so a probe built
2993        // from it alone could never fill them in: keep the references too, via
2994        // [`Self::to_parts`].
2995        if !self.headers.is_empty() {
2996            return Err(
2997                "health check headers from the configuration file are not available yet: their values need the protected channel",
2998            );
2999        }
3000        self.to_parts().map(|(config, _)| config)
3001    }
3002
3003    /// The health check as metadata plus the ordered `value_file` references
3004    /// of its headers. The metadata names the headers and leaves every
3005    /// fingerprint empty; names are validated, the files are not touched.
3006    pub fn to_parts(
3007        &self,
3008    ) -> Result<(HealthCheckConfig, Vec<FileHealthCheckHeader>), &'static str> {
3009        let accepted_statuses = self
3010            .accepted_statuses
3011            .iter()
3012            .map(|spec| parse_http_status_range(spec))
3013            .collect::<Result<Vec<_>, _>>()?;
3014        let proto = HealthCheckConfig {
3015            uri: self.uri.to_owned(),
3016            interval: self.interval,
3017            timeout: self.timeout,
3018            healthy_threshold: self.healthy_threshold,
3019            unhealthy_threshold: self.unhealthy_threshold,
3020            expected_status: self.expected_status,
3021            mode: self.mode.unwrap_or(HealthCheckMode::Http) as i32,
3022            accepted_statuses,
3023            port: self.port,
3024            headers: self
3025                .headers
3026                .iter()
3027                .map(|header| HealthCheckHeader {
3028                    name: header.name.to_owned(),
3029                    fingerprint: String::new(),
3030                })
3031                .collect(),
3032        };
3033        // POST: the proto mirrors the file config exactly — the URI and all
3034        // timing knobs are carried through verbatim (no clamping or defaulting
3035        // happens at this layer; defaults are applied by serde at parse time).
3036        debug_assert_eq!(proto.uri, self.uri, "proto URI must mirror the file config");
3037        debug_assert!(
3038            proto.interval == self.interval
3039                && proto.timeout == self.timeout
3040                && proto.healthy_threshold == self.healthy_threshold
3041                && proto.unhealthy_threshold == self.unhealthy_threshold,
3042            "proto timing knobs must mirror the file config"
3043        );
3044        debug_assert_eq!(
3045            proto.port, self.port,
3046            "proto probe port must mirror the file config"
3047        );
3048        debug_assert_eq!(
3049            proto.accepted_statuses.len(),
3050            self.accepted_statuses.len(),
3051            "every accepted status spec must map to exactly one range"
3052        );
3053        debug_assert_eq!(
3054            proto.headers.len(),
3055            self.headers.len(),
3056            "every configured header must map to exactly one proto header"
3057        );
3058        validate_health_check_config(&proto)?;
3059        Ok((proto, self.headers.clone()))
3060    }
3061}
3062
3063/// Lowest and highest valid HTTP status codes: RFC 9110 §15 calls values
3064/// outside `100..=599` invalid.
3065pub const HTTP_STATUS_MIN: u32 = 100;
3066pub const HTTP_STATUS_MAX: u32 = 599;
3067
3068/// Parse one `accepted_statuses` entry of the TOML file or the CLI:
3069///
3070/// - `"any"`: every valid status, `100-599` — any well-formed HTTP response;
3071/// - `"Nxx"` with `N` in `1..=5`: the class `N00-N99`;
3072/// - `"A-B"`: the inclusive range `A..=B`;
3073/// - `"A"`: the single status `A`.
3074///
3075/// Bounds must lie in `100..=599` and `A <= B`.
3076pub fn parse_http_status_range(spec: &str) -> Result<HttpStatusRange, &'static str> {
3077    const INVALID: &str = "health check accepted status must be a code (\"404\"), a range \
3078                           (\"200-399\"), a class (\"2xx\") or \"any\"";
3079    let spec = spec.trim();
3080    let range = if spec.eq_ignore_ascii_case("any") {
3081        HttpStatusRange {
3082            start: HTTP_STATUS_MIN,
3083            end: HTTP_STATUS_MAX,
3084        }
3085    } else if let [class @ b'1'..=b'5', x1, x2] = spec.as_bytes()
3086        && x1.eq_ignore_ascii_case(&b'x')
3087        && x2.eq_ignore_ascii_case(&b'x')
3088    {
3089        let start = u32::from(class - b'0') * 100;
3090        HttpStatusRange {
3091            start,
3092            end: start + 99,
3093        }
3094    } else if let Some((start, end)) = spec.split_once('-') {
3095        HttpStatusRange {
3096            start: parse_status_code(start).ok_or(INVALID)?,
3097            end: parse_status_code(end).ok_or(INVALID)?,
3098        }
3099    } else {
3100        let code = parse_status_code(spec).ok_or(INVALID)?;
3101        HttpStatusRange {
3102            start: code,
3103            end: code,
3104        }
3105    };
3106    validate_http_status_range(&range)?;
3107    Ok(range)
3108}
3109
3110/// A status code is ASCII digits only: `u32::from_str` alone would also take
3111/// a leading `+`.
3112fn parse_status_code(code: &str) -> Option<u32> {
3113    let code = code.trim();
3114    if code.is_empty() || !code.bytes().all(|b| b.is_ascii_digit()) {
3115        return None;
3116    }
3117    code.parse().ok()
3118}
3119
3120fn validate_http_status_range(range: &HttpStatusRange) -> Result<(), &'static str> {
3121    if range.start < HTTP_STATUS_MIN || range.end > HTTP_STATUS_MAX {
3122        return Err("health check accepted statuses must lie within 100-599");
3123    }
3124    if range.start > range.end {
3125        return Err("health check accepted status range must not end before it starts");
3126    }
3127    debug_assert!(
3128        (HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&range.start)
3129            && (HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&range.end),
3130        "a validated range must lie within the valid status codes"
3131    );
3132    Ok(())
3133}
3134
3135/// Validate a cluster's client affinity key source, `affinity_header` or
3136/// `affinity_cookie`.
3137///
3138/// At most one of the two may be set, and the one that is must be an RFC 9110
3139/// `token` (a cookie name is a `token` too, RFC 6265 §4.1.1): a name no
3140/// request can carry would silently key every request on its source IP.
3141///
3142/// Two header names are refused although they are tokens, because the key
3143/// lookup can never find them among the request's headers: `Host`, which the
3144/// H1 parser folds into the request authority and elides (H2 carries it as
3145/// `:authority`), and `Cookie`, whose crumbs both parsers move into the cookie
3146/// jar — key on one cookie with `affinity_cookie` instead. Names compare
3147/// case-insensitively, as header names do.
3148///
3149/// Called on every `AddCluster`, so a TOML load, a reload, a saved state and
3150/// a direct API request are held to the same rule as `sozu cluster add`.
3151pub fn validate_affinity_key(
3152    affinity_header: Option<&str>,
3153    affinity_cookie: Option<&str>,
3154) -> Result<(), &'static str> {
3155    match (affinity_header, affinity_cookie) {
3156        (Some(_), Some(_)) => {
3157            Err("affinity_header and affinity_cookie are mutually exclusive: set at most one")
3158        }
3159        (Some(name), None) if !header_name_is_valid_token(name.as_bytes()) => {
3160            Err("affinity_header must be a non-empty RFC 9110 token (a header field name)")
3161        }
3162        (Some(name), None) if name.eq_ignore_ascii_case("host") => Err(
3163            "affinity_header cannot be Host: the request authority is not a header Sōzu keeps \
3164             (H1 folds Host into it, H2 sends :authority)",
3165        ),
3166        (Some(name), None) if name.eq_ignore_ascii_case("cookie") => Err(
3167            "affinity_header cannot be Cookie: cookies are parsed into a jar; key on one with \
3168             affinity_cookie instead",
3169        ),
3170        (None, Some(name)) if !header_name_is_valid_token(name.as_bytes()) => {
3171            Err("affinity_cookie must be a non-empty RFC 6265 cookie name (an RFC 9110 token)")
3172        }
3173        _ => Ok(()),
3174    }
3175}
3176
3177/// Default `shard_min_backends`: the reference fleet of *Workload isolation
3178/// using shuffle sharding* (Colm MacCárthaigh, Amazon Builders' Library,
3179/// 2019), eight workers in shards of two. Below it the combinations are too
3180/// few to isolate anything: with four backends in shards of two, six shards
3181/// exist and nearly every pair of clients overlaps.
3182pub const DEFAULT_SHARD_MIN_BACKENDS: u32 = 8;
3183
3184/// Validate a cluster's shuffle-sharding knobs (sozu-proxy/sozu#524).
3185///
3186/// `shard_percent` turns sharding on and must lie in `1..=100`;
3187/// `shard_min_backends` must be at least 2, the smallest shard; and
3188/// `shard_min_backends` or `shard_mode` without `shard_percent` is refused
3189/// rather than silently inert. Called at config load and on every
3190/// `AddCluster`.
3191pub fn validate_shuffle_sharding(
3192    shard_percent: Option<u32>,
3193    shard_min_backends: Option<u32>,
3194    shard_mode: Option<i32>,
3195) -> Result<(), &'static str> {
3196    let Some(percent) = shard_percent else {
3197        if shard_min_backends.is_some() || shard_mode.is_some() {
3198            return Err("shard_min_backends and shard_mode require shard_percent");
3199        }
3200        return Ok(());
3201    };
3202    if !(1..=100).contains(&percent) {
3203        return Err("shard_percent must lie in 1..=100");
3204    }
3205    if shard_min_backends.is_some_and(|minimum| minimum < 2) {
3206        return Err("shard_min_backends must be at least 2, the smallest shard");
3207    }
3208    if shard_mode.is_some_and(|mode| ShardMode::try_from(mode).is_err()) {
3209        return Err("shard_mode must be FALLBACK or STRICT");
3210    }
3211    Ok(())
3212}
3213
3214/// Most custom request headers a health check may carry.
3215pub const HEALTH_CHECK_MAX_HEADERS: usize = 32;
3216/// Most `name + value + 4` bytes (the `: ` and CRLF an HTTP/1.1 line adds)
3217/// the custom headers of a health check may sum to.
3218pub const HEALTH_CHECK_MAX_HEADER_BYTES: usize = 8192;
3219/// Largest HPACK block the h2c probe may send: one HEADERS frame, which the
3220/// probe never splits, whose payload stays within the default
3221/// `SETTINGS_MAX_FRAME_SIZE` of 16384 bytes (RFC 9113 §6.5.2).
3222pub const HEALTH_CHECK_MAX_HPACK_BLOCK: usize = 16384;
3223/// Longest `:authority` the h2c probe can name: a [`std::net::SocketAddr`]
3224/// `Display` is at most `[` + 39 IPv6 digits + `%4294967295` + `]` + `:65535`.
3225pub const HEALTH_CHECK_MAX_AUTHORITY_LEN: usize = 1 + 39 + 11 + 1 + 6;
3226
3227/// Header names the probe owns or that would change the connection framing.
3228const HEALTH_CHECK_FORBIDDEN_HEADERS: [&str; 10] = [
3229    "host",
3230    "connection",
3231    "keep-alive",
3232    "proxy-connection",
3233    "transfer-encoding",
3234    "content-length",
3235    "te",
3236    "trailer",
3237    "upgrade",
3238    "expect",
3239];
3240
3241/// Bytes of an HPACK integer with a 7-bit prefix (RFC 7541 §5.1).
3242fn hpack_string_length_bytes(length: usize) -> usize {
3243    if length < 127 {
3244        return 1;
3245    }
3246    let mut rest = length - 127;
3247    let mut bytes = 1;
3248    loop {
3249        bytes += 1;
3250        rest >>= 7;
3251        if rest == 0 {
3252            return bytes;
3253        }
3254    }
3255}
3256
3257/// Upper bound of the HPACK block the h2c probe encodes for `cfg`: the
3258/// four pseudo-headers with the longest possible `:authority`, then every
3259/// custom field as a literal with a new name (flag byte, the two string
3260/// lengths, name, value), none Huffman coded.
3261pub fn health_check_hpack_upper_bound(cfg: &HealthCheckConfig, value_lens: &[usize]) -> usize {
3262    // `:method GET` and `:scheme http` are static-table entries, one byte each.
3263    let mut bound = 2;
3264    // `:path`: an indexed name, or the whole `/` entry, plus the literal URI.
3265    bound += 1 + hpack_string_length_bytes(cfg.uri.len()) + cfg.uri.len();
3266    // `:authority`: an indexed name plus the literal address.
3267    bound += 1
3268        + hpack_string_length_bytes(HEALTH_CHECK_MAX_AUTHORITY_LEN)
3269        + HEALTH_CHECK_MAX_AUTHORITY_LEN;
3270    for (header, value_len) in cfg.headers.iter().zip(value_lens) {
3271        bound += 1
3272            + hpack_string_length_bytes(header.name.len())
3273            + header.name.len()
3274            + hpack_string_length_bytes(*value_len)
3275            + value_len;
3276    }
3277    bound
3278}
3279
3280/// The per-value rules of a health-check header: no forbidden control byte,
3281/// and no leading or trailing SP or HTAB. Single source for
3282/// [`validate_health_check_headers`] and for the protected ingress value
3283/// (`crate::secret::SecretValue`); the diagnostics are static, they never
3284/// echo the value.
3285pub(crate) fn check_health_check_header_value(value: &str) -> Result<(), &'static str> {
3286    if header_value_contains_forbidden_controls(value.as_bytes()) {
3287        return Err("health check header value must not contain control bytes");
3288    }
3289    // RFC 9113 §8.2.1: a field value must not begin or end with SP or HTAB
3290    // (an HTTP/2 peer treats such a request as malformed). Protobuf and TOML
3291    // values keep their exact bytes, so they are refused instead of being
3292    // silently rewritten.
3293    if value.starts_with([' ', '\t']) || value.ends_with([' ', '\t']) {
3294        return Err("health check header value must not begin or end with a space or a tab");
3295    }
3296    Ok(())
3297}
3298
3299/// Validate the names of the custom `headers` of a health check, which is all
3300/// a configuration holds of them. Diagnostics are static: they never echo a
3301/// configured name.
3302fn validate_health_check_headers(
3303    cfg: &HealthCheckConfig,
3304    mode: HealthCheckMode,
3305) -> Result<(), &'static str> {
3306    if cfg.headers.is_empty() {
3307        return Ok(());
3308    }
3309    if mode == HealthCheckMode::Tcp {
3310        return Err("health check headers apply to HTTP mode only: leave them unset with mode TCP");
3311    }
3312    if cfg.headers.len() > HEALTH_CHECK_MAX_HEADERS {
3313        return Err("health check carries more than 32 headers");
3314    }
3315    for (index, header) in cfg.headers.iter().enumerate() {
3316        if header.name.starts_with(':')
3317            || HEALTH_CHECK_FORBIDDEN_HEADERS
3318                .iter()
3319                .any(|forbidden| header.name.eq_ignore_ascii_case(forbidden))
3320        {
3321            return Err("health check header name is reserved for the probe or its framing");
3322        }
3323        if !header_name_is_valid_token(header.name.as_bytes()) {
3324            return Err("health check header name must be a non-empty RFC 9110 token");
3325        }
3326        if cfg.headers[..index]
3327            .iter()
3328            .any(|earlier| earlier.name.eq_ignore_ascii_case(&header.name))
3329        {
3330            return Err("health check header names must be unique, compared case-insensitively");
3331        }
3332        // Empty is an operator order the main process has yet to fingerprint;
3333        // anything else must be a fingerprint, or it is not metadata at all.
3334        if !header.fingerprint.is_empty()
3335            && header
3336                .fingerprint
3337                .parse::<crate::secret::HeaderFingerprint>()
3338                .is_err()
3339        {
3340            return Err("health check header fingerprint is malformed");
3341        }
3342    }
3343    Ok(())
3344}
3345
3346/// Validate the clear `values` of the custom headers of `cfg`, whose names
3347/// [`validate_health_check_config`] validates: one value per header in the
3348/// same order and under the same name, each meeting the per-value rules,
3349/// within the 8192-byte wire sum and the one-frame HTTP/2 block. Diagnostics
3350/// are static: they never echo a name or a value, nor any length of a value.
3351pub fn validate_health_check_header_values(
3352    cfg: &HealthCheckConfig,
3353    values: &[HealthCheckHeaderValue],
3354) -> Result<(), &'static str> {
3355    if values.len() != cfg.headers.len() {
3356        return Err("health check carries a header value count different from its header count");
3357    }
3358    let mut wire_bytes = 0usize;
3359    for (header, carrier) in cfg.headers.iter().zip(values) {
3360        if header.name != carrier.name {
3361            return Err(
3362                "health check header values must follow the order and names of the headers",
3363            );
3364        }
3365        let text = std::str::from_utf8(&carrier.value)
3366            .map_err(|_| "health check header value must be valid UTF-8")?;
3367        check_health_check_header_value(text)?;
3368        wire_bytes = wire_bytes
3369            .saturating_add(header.name.len())
3370            .saturating_add(carrier.value.len())
3371            .saturating_add(4);
3372    }
3373    if wire_bytes > HEALTH_CHECK_MAX_HEADER_BYTES {
3374        return Err("health check headers exceed 8192 bytes (name + value + 4 per header)");
3375    }
3376    let value_lens: Vec<usize> = values.iter().map(|carrier| carrier.value.len()).collect();
3377    if health_check_hpack_upper_bound(cfg, &value_lens) > HEALTH_CHECK_MAX_HPACK_BLOCK {
3378        return Err("health check URI and headers may exceed the 16384-byte HTTP/2 probe block");
3379    }
3380    Ok(())
3381}
3382
3383/// Validate a [`HealthCheckConfig`] for the rules every layer relies on:
3384/// a known probe mode, strict positive thresholds, a URI that cannot
3385/// smuggle a second HTTP message on the wire (RFC 9110 §5.1 —
3386/// request-target; the leading `/` is only required in HTTP mode, the
3387/// only one that sends it), accepted status ranges within `100..=599`,
3388/// at most one of `expected_status` / `accepted_statuses`, neither in
3389/// TCP mode, which judges no status, and a probe `port`, when set, within
3390/// `1..=65535`. Used by
3391/// the CLI request builder and the worker `SetHealthCheck` handler so
3392/// off-channel inputs (TOML reload, third-party clients) are
3393/// constrained the same way as `sozu cluster health-check set`.
3394///
3395/// The function is intentionally [`Result<(), &'static str>`] rather
3396/// than carrying a structured error: the diagnostics only flow into
3397/// CLI output / worker error responses where the message is the value.
3398pub fn validate_health_check_config(cfg: &HealthCheckConfig) -> Result<(), &'static str> {
3399    let Ok(mode) = HealthCheckMode::try_from(cfg.mode) else {
3400        return Err("health check mode must be HTTP or TCP");
3401    };
3402    if cfg.interval == 0 {
3403        return Err("health check interval must be > 0");
3404    }
3405    if cfg.timeout == 0 {
3406        return Err("health check timeout must be > 0");
3407    }
3408    if cfg.healthy_threshold == 0 {
3409        return Err("health check healthy_threshold must be > 0");
3410    }
3411    if cfg.unhealthy_threshold == 0 {
3412        return Err("health check unhealthy_threshold must be > 0");
3413    }
3414    // The URI only reaches the wire in HTTP mode; TCP mode ignores it and
3415    // may leave it empty. The control-byte check below still applies, so a
3416    // stored config never carries a URI that would smuggle a second message
3417    // if displayed or later reused.
3418    if mode == HealthCheckMode::Http && !cfg.uri.starts_with('/') {
3419        return Err("health check URI must start with '/'");
3420    }
3421    if cfg
3422        .uri
3423        .bytes()
3424        .any(|b| b == b'\r' || b == b'\n' || b == 0 || (b < 0x20 && b != b'\t'))
3425    {
3426        return Err("health check URI must not contain CR, LF, NUL, or other C0 control bytes");
3427    }
3428    for range in &cfg.accepted_statuses {
3429        validate_http_status_range(range)?;
3430    }
3431    if cfg.expected_status != 0 && !cfg.accepted_statuses.is_empty() {
3432        return Err(
3433            "health check expected_status and accepted_statuses are mutually exclusive: \
3434             set at most one",
3435        );
3436    }
3437    // A TCP probe judges no status: a status field set beside it would read
3438    // as a constraint the probe silently does not apply.
3439    if mode == HealthCheckMode::Tcp
3440        && (cfg.expected_status != 0 || !cfg.accepted_statuses.is_empty())
3441    {
3442        return Err(
3443            "health check expected_status and accepted_statuses apply to HTTP mode only: \
3444             leave them unset with mode TCP",
3445        );
3446    }
3447    // The probe dials `ip:port`: 0 names no port, and a TCP port is 16 bits.
3448    if cfg
3449        .port
3450        .is_some_and(|port| !(1..=u32::from(u16::MAX)).contains(&port))
3451    {
3452        return Err("health check port must lie in 1..=65535");
3453    }
3454    validate_health_check_headers(cfg, mode)?;
3455    // POST: a validated config has strictly-positive timing knobs (a zero
3456    // interval/timeout/threshold would make the health-check loop spin or
3457    // never converge) and a request-target the worker can splice into an HTTP
3458    // probe without smuggling a second message. Every Ok return is a witness
3459    // for all of these.
3460    debug_assert!(
3461        cfg.interval > 0
3462            && cfg.timeout > 0
3463            && cfg.healthy_threshold > 0
3464            && cfg.unhealthy_threshold > 0,
3465        "validated health-check thresholds must all be strictly positive"
3466    );
3467    debug_assert!(
3468        mode == HealthCheckMode::Tcp || cfg.uri.starts_with('/'),
3469        "a validated HTTP health-check URI must be an absolute path"
3470    );
3471    debug_assert!(
3472        cfg.expected_status == 0 || cfg.accepted_statuses.is_empty(),
3473        "a validated health check sets at most one of expected_status and accepted_statuses"
3474    );
3475    debug_assert!(
3476        mode == HealthCheckMode::Http
3477            || (cfg.expected_status == 0 && cfg.accepted_statuses.is_empty()),
3478        "a validated TCP health check sets no status field"
3479    );
3480    debug_assert!(
3481        cfg.port
3482            .is_none_or(|port| port != 0 && u16::try_from(port).is_ok()),
3483        "a validated health-check probe port is a non-zero 16-bit port"
3484    );
3485    Ok(())
3486}
3487
3488#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3489#[serde(deny_unknown_fields)]
3490pub struct FileClusterConfig {
3491    pub frontends: Vec<FileClusterFrontendConfig>,
3492    pub backends: Vec<FileBackendConfig>,
3493    pub protocol: FileClusterProtocolConfig,
3494    pub sticky_session: Option<bool>,
3495    pub https_redirect: Option<bool>,
3496    #[serde(default)]
3497    pub send_proxy: Option<bool>,
3498    #[serde(default)]
3499    pub load_balancing: LoadBalancingAlgorithms,
3500    pub answer_503: Option<String>,
3501    #[serde(default)]
3502    pub load_metric: Option<LoadMetric>,
3503    /// Backend-capability hint: `true` when the backend speaks HTTP/2 (h2c or h2+TLS once #1218 lands).
3504    /// Does NOT gate H2 at the frontend — frontend H2 is ALPN-negotiated independently (see `alpn_protocols`).
3505    pub http2: Option<bool>,
3506    /// Per-cluster HTTP answer template overrides keyed by HTTP status
3507    /// code (e.g. `"503"`). Each value is either a filesystem path or an
3508    /// `inline:<body>` literal — see [`resolve_answer_source`]. Loaded
3509    /// into [`Cluster::answers`] at build time via [`load_answers`].
3510    ///
3511    /// Layering: an entry here overrides the listener-level
3512    /// `[listeners.<id>.answers]` default for the matching status on
3513    /// requests routed to this cluster. The listener-level map is the
3514    /// global default; the cluster-level map is the per-cluster
3515    /// override.
3516    pub answers: Option<BTreeMap<String, String>>,
3517    /// Optional explicit port to use when building the `Location` header
3518    /// for an `https_redirect`. When unset, the listener's effective HTTPS
3519    /// port is used. Lets operators front a non-standard HTTPS port (e.g.
3520    /// 8443) on the redirect target while keeping `https_redirect = true`.
3521    pub https_redirect_port: Option<u32>,
3522    /// Authorized credentials for HTTP basic authentication, formatted as
3523    /// `username:hex(sha256(password))` (lower-case hex). Empty list
3524    /// disables auth even when a frontend sets `required_auth = true` —
3525    /// such requests are rejected with 401.
3526    pub authorized_hashes: Option<Vec<String>>,
3527    /// Realm string emitted in `WWW-Authenticate: Basic realm="…"` when
3528    /// an unauthenticated request is rejected. Treated as an opaque
3529    /// value (no template substitution).
3530    pub www_authenticate: Option<String>,
3531    /// Override the global per-(cluster, source-IP) connection limit for
3532    /// this cluster. `None` (field absent) inherits the global default
3533    /// `max_connections_per_ip`. `Some(0)` is explicit "unlimited for
3534    /// this cluster". `Some(n > 0)` overrides with the cluster-specific
3535    /// limit. The source IP is taken from the parsed proxy-protocol
3536    /// header when present, else `peer_addr`. On a UDP cluster it limits
3537    /// concurrent flows per datagram source address; UDP never inherits the
3538    /// global default.
3539    pub max_connections_per_ip: Option<u64>,
3540    /// Override the global per-(cluster, source-SUBNET) connection limit
3541    /// for this cluster. Same three-state semantics as
3542    /// [`FileClusterConfig::max_connections_per_ip`]: `None` inherits,
3543    /// `Some(0)` is explicit unlimited, `Some(n > 0)` overrides. This is
3544    /// an independent counter — a connection must satisfy BOTH caps.
3545    /// The mask itself is global and boot-time only (`subnet_ipv4_prefix`
3546    /// / `subnet_ipv6_prefix`), not overridable per cluster.
3547    pub max_connections_per_subnet: Option<u64>,
3548    /// Override the global `Retry-After` header value (seconds) emitted
3549    /// on HTTP 429 responses for this cluster. `None` inherits the global
3550    /// default. `Some(0)` omits the header. TCP clusters carry this
3551    /// field for shape uniformity but never emit the header (no HTTP
3552    /// envelope).
3553    pub retry_after: Option<u32>,
3554    /// Optional HTTP health-check configuration. The probe wire format
3555    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
3556    /// (h2c) when true.
3557    #[serde(default)]
3558    pub health_check: Option<FileHealthCheckConfig>,
3559    /// Optional UDP-specific cluster configuration, parsed from a
3560    /// `[clusters.<id>.udp]` block. Additive: clusters without a `udp`
3561    /// block produce `udp: None` on the resulting [`Cluster`].
3562    #[serde(default)]
3563    pub udp: Option<FileUdpClusterConfig>,
3564    /// Key an HTTP/HTTPS cluster's `HRW`/`MAGLEV` selection on the value of
3565    /// this request header instead of the client's source IP. A request
3566    /// without the header falls back to its source IP. Mutually exclusive
3567    /// with `affinity_cookie`; refused on a TCP cluster, which has no request
3568    /// headers and always keys on the source IP.
3569    #[serde(default)]
3570    pub affinity_header: Option<String>,
3571    /// Same as `affinity_header`, on the value of this request cookie.
3572    #[serde(default)]
3573    pub affinity_cookie: Option<String>,
3574    /// Shuffle sharding: restrict each client to a shard of
3575    /// `max(2, ceil(shard_percent × N / 100))` of the cluster's N primary
3576    /// backends, the top of the rendezvous ranking of its affinity key.
3577    /// Off when unset. See [`validate_shuffle_sharding`].
3578    #[serde(default)]
3579    pub shard_percent: Option<u32>,
3580    /// Sharding applies only from this many primary backends; defaults to
3581    /// [`DEFAULT_SHARD_MIN_BACKENDS`].
3582    #[serde(default)]
3583    pub shard_min_backends: Option<u32>,
3584    /// `FALLBACK` (default) spills an exhausted shard over to the rest of the
3585    /// cluster; `STRICT` refuses.
3586    #[serde(default)]
3587    pub shard_mode: Option<ShardMode>,
3588    /// Override the global `max_connection_attempts` for this cluster: how
3589    /// many backend connections a request may try, the first included,
3590    /// before it is answered `503` (HTTP) or closed (TCP). `None` inherits
3591    /// the global value. See [`validate_max_connection_attempts`].
3592    #[serde(default)]
3593    pub max_connection_attempts: Option<u32>,
3594}
3595
3596/// UDP backend health-check configuration, parsed from
3597/// `[clusters.<id>.udp.health]`.
3598#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3599#[serde(deny_unknown_fields)]
3600pub struct FileUdpHealthConfig {
3601    /// probe mode, parsed in SCREAMING_SNAKE_CASE: `"HEALTH_OFF"`,
3602    /// `"TCP_PROBE"`, or `"UDP_PROBE"`. Defaults to `TCP_PROBE` when a
3603    /// `udp.health` block is present.
3604    pub mode: Option<UdpHealthMode>,
3605    pub tcp_port: Option<u32>,
3606    pub rise: Option<u32>,
3607    pub fall: Option<u32>,
3608    pub fail_open: Option<bool>,
3609    /// hex-free literal payload sent for a UDP probe.
3610    pub udp_probe_payload: Option<String>,
3611    pub probe_interval_seconds: Option<u32>,
3612    pub probe_timeout_seconds: Option<u32>,
3613}
3614
3615impl FileUdpHealthConfig {
3616    pub fn to_proto(&self) -> UdpHealthConfig {
3617        UdpHealthConfig {
3618            mode: self.mode.map(|m| m as i32),
3619            tcp_port: self.tcp_port,
3620            rise: self.rise,
3621            fall: self.fall,
3622            fail_open: self.fail_open,
3623            udp_probe_payload: self
3624                .udp_probe_payload
3625                .as_ref()
3626                .map(|p| p.as_bytes().to_owned()),
3627            probe_interval_seconds: self.probe_interval_seconds,
3628            probe_timeout_seconds: self.probe_timeout_seconds,
3629        }
3630    }
3631}
3632
3633/// UDP-specific cluster knobs, parsed from a `[clusters.<id>.udp]` block.
3634#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3635#[serde(deny_unknown_fields)]
3636pub struct FileUdpClusterConfig {
3637    /// flow affinity key, parsed in SCREAMING_SNAKE_CASE: `"SOURCE_IP"`
3638    /// (default) or `"SOURCE_IP_PORT"`.
3639    pub affinity_key: Option<UdpAffinityKey>,
3640    /// expected replies per flow; 0 = unlimited.
3641    pub responses: Option<u32>,
3642    /// max client datagrams per flow; 0 = unlimited.
3643    pub requests: Option<u32>,
3644    /// send a PROXY protocol v2 header to the backend.
3645    pub send_proxy_protocol: Option<bool>,
3646    /// prepend PPv2 to every datagram; false = first-datagram only.
3647    pub proxy_protocol_every_datagram: Option<bool>,
3648    /// optional backend health-check configuration.
3649    pub health: Option<FileUdpHealthConfig>,
3650}
3651
3652impl FileUdpClusterConfig {
3653    pub fn to_proto(&self) -> UdpClusterConfig {
3654        UdpClusterConfig {
3655            affinity_key: self.affinity_key.map(|k| k as i32),
3656            responses: self.responses,
3657            requests: self.requests,
3658            send_proxy_protocol: self.send_proxy_protocol,
3659            proxy_protocol_every_datagram: self.proxy_protocol_every_datagram,
3660            health: self.health.as_ref().map(|h| h.to_proto()),
3661        }
3662    }
3663}
3664
3665#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3666#[serde(deny_unknown_fields)]
3667pub struct BackendConfig {
3668    pub address: SocketAddr,
3669    pub weight: Option<u8>,
3670    pub sticky_id: Option<String>,
3671    pub backup: Option<bool>,
3672    pub backend_id: Option<String>,
3673    /// Dial this UNIX domain socket (absolute path, or `@name` for the Linux
3674    /// abstract namespace) instead of `address`, which then only identifies
3675    /// the backend within its cluster and must be unique there.
3676    pub unix_path: Option<String>,
3677}
3678
3679/// Check the `unix_path` of a cluster's backends: each parses, and each unix
3680/// backend's identity `address` is used by no other backend of the cluster,
3681/// since sozu tells backends apart by address.
3682fn validate_backend_unix_paths(
3683    cluster_id: &str,
3684    backends: &[BackendConfig],
3685) -> Result<(), ConfigError> {
3686    for (index, backend) in backends.iter().enumerate() {
3687        let Some(unix_path) = backend.unix_path.as_deref() else {
3688            continue;
3689        };
3690        let invalid = |reason| ConfigError::InvalidBackendUnixPath {
3691            cluster_id: cluster_id.to_owned(),
3692            address: backend.address,
3693            reason,
3694        };
3695        parse_backend_unix_path(unix_path).map_err(invalid)?;
3696        if backends
3697            .iter()
3698            .enumerate()
3699            .any(|(other, b)| other != index && b.address == backend.address)
3700        {
3701            return Err(invalid(
3702                "a unix backend's address is its identity and must be unique in the cluster",
3703            ));
3704        }
3705    }
3706    Ok(())
3707}
3708
3709/// Turns a `host:port` string into socket addresses.
3710pub type BackendResolver<'a> = dyn Fn(&str) -> std::io::Result<Vec<SocketAddr>> + 'a;
3711
3712/// The system resolver (`std::net::ToSocketAddrs`).
3713pub fn system_resolver(address: &str) -> std::io::Result<Vec<SocketAddr>> {
3714    address.to_socket_addrs().map(Iterator::collect)
3715}
3716
3717/// A backend as written in the configuration file. `address` is either a
3718/// socket address (`"127.0.0.1:8080"`, `"[::1]:8080"`) or a `host:port`
3719/// (`"ingress:8080"`) that [`FileBackendConfig::resolve`] turns into one
3720/// [`BackendConfig`] per resolved address when the file is loaded
3721/// (sozu-proxy/sozu#347). Workers only ever receive socket addresses.
3722#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3723#[serde(deny_unknown_fields)]
3724pub struct FileBackendConfig {
3725    pub address: String,
3726    pub weight: Option<u8>,
3727    pub sticky_id: Option<String>,
3728    pub backup: Option<bool>,
3729    pub backend_id: Option<String>,
3730    /// Carried unchanged onto every backend this entry resolves to; see
3731    /// [`BackendConfig::unix_path`].
3732    pub unix_path: Option<String>,
3733}
3734
3735/// Whether `host` is spelled like a legacy `inet_aton` address: every
3736/// dot-separated component is non-empty and either all decimal/octal digits
3737/// or `0x`/`0X` followed by ASCII hex digits. Ordinary names (`dead.beef`,
3738/// `123.example.com`) have a non-numeric component and do not match.
3739fn is_legacy_numeric_host(host: &str) -> bool {
3740    host.split('.').all(|component| {
3741        !component.is_empty()
3742            && match component
3743                .strip_prefix("0x")
3744                .or_else(|| component.strip_prefix("0X"))
3745            {
3746                Some(hex) => hex.bytes().all(|byte| byte.is_ascii_hexdigit()),
3747                None => component.bytes().all(|byte| byte.is_ascii_digit()),
3748            }
3749    })
3750}
3751
3752impl FileBackendConfig {
3753    /// Resolves this entry into backends, once, through `resolver`
3754    /// ([`system_resolver`] outside tests).
3755    ///
3756    /// A socket-address literal never reaches the resolver and keeps its
3757    /// configured `backend_id`, or the positional default id
3758    /// `<cluster_id>-<index>-<address>`, where `index` is the entry's position
3759    /// in the file's `backends` list, so it does not move when a name before
3760    /// it resolves to more or fewer addresses. A `host:port`
3761    /// yields one backend per distinct resolved address, sorted so the
3762    /// result does not depend on the resolver's answer order, each with a
3763    /// deterministic id: `<backend_id>-<address>` when `backend_id` is set,
3764    /// `<cluster_id>-<host:port>-<address>` otherwise. When the name
3765    /// resolves, a configured `sticky_id` is suffixed with `-<address>` too,
3766    /// so it stays unique in the cluster and does not change when the answer
3767    /// grows or shrinks.
3768    ///
3769    /// A host made only of dot-separated decimal, octal or `0x` hexadecimal
3770    /// components that is not a valid IP literal (`010.0.0.1`, `1.2.3`,
3771    /// `0x7f.1`) is refused before the resolver: getaddrinfo reads such
3772    /// strings as legacy `inet_aton` shorthand (`8.0.0.1`, `1.2.0.3`,
3773    /// `127.0.0.1`), which the socket-address parser always rejected. An
3774    /// unbracketed IPv6 literal (`::1:8080`) is refused the same way:
3775    /// getaddrinfo would split it on the last colon.
3776    pub fn resolve(
3777        &self,
3778        cluster_id: &str,
3779        index: usize,
3780        resolver: &BackendResolver<'_>,
3781    ) -> Result<Vec<BackendConfig>, ConfigError> {
3782        if let Ok(address) = self.address.parse::<SocketAddr>() {
3783            return Ok(vec![BackendConfig {
3784                address,
3785                weight: self.weight,
3786                sticky_id: self.sticky_id.clone(),
3787                backup: self.backup,
3788                backend_id: Some(
3789                    self.backend_id
3790                        .to_owned()
3791                        .unwrap_or_else(|| format!("{cluster_id}-{index}-{address}")),
3792                ),
3793                unix_path: self.unix_path.clone(),
3794            }]);
3795        }
3796
3797        let error = |reason: String| ConfigError::BackendAddressResolution {
3798            cluster_id: cluster_id.to_owned(),
3799            address: self.address.clone(),
3800            reason,
3801        };
3802        if let Some((host, _port)) = self.address.rsplit_once(':') {
3803            if host.contains(':') {
3804                return Err(error(format!(
3805                    "{host:?} is an unbracketed IPv6 literal, IPv6 literals must use [address]:port"
3806                )));
3807            }
3808            if !host.is_empty() && is_legacy_numeric_host(host) {
3809                return Err(error(format!(
3810                    "{host:?} is not a valid IP address literal and numeric shorthand is not accepted"
3811                )));
3812            }
3813        }
3814        let addresses = resolver(&self.address).map_err(|io_error| error(io_error.to_string()))?;
3815        self.expand(cluster_id, addresses)
3816    }
3817
3818    /// Turns the addresses a `host:port` entry resolved to into backends:
3819    /// the naming half of [`FileBackendConfig::resolve`], kept apart from
3820    /// the resolver so it can be exercised with any set of addresses.
3821    fn expand(
3822        &self,
3823        cluster_id: &str,
3824        mut addresses: Vec<SocketAddr>,
3825    ) -> Result<Vec<BackendConfig>, ConfigError> {
3826        addresses.sort_unstable();
3827        addresses.dedup();
3828        if addresses.is_empty() {
3829            return Err(ConfigError::BackendAddressResolution {
3830                cluster_id: cluster_id.to_owned(),
3831                address: self.address.clone(),
3832                reason: "the name resolved to no address".to_owned(),
3833            });
3834        }
3835
3836        let backends: Vec<BackendConfig> = addresses
3837            .iter()
3838            .map(|address| BackendConfig {
3839                address: *address,
3840                weight: self.weight,
3841                sticky_id: self
3842                    .sticky_id
3843                    .as_ref()
3844                    .map(|sticky_id| format!("{sticky_id}-{address}")),
3845                backup: self.backup,
3846                backend_id: Some(match &self.backend_id {
3847                    Some(backend_id) => format!("{backend_id}-{address}"),
3848                    None => format!("{cluster_id}-{}-{address}", self.address),
3849                }),
3850                unix_path: self.unix_path.clone(),
3851            })
3852            .collect();
3853
3854        // POST: one backend per distinct resolved address, every one carrying
3855        // a distinct id. Downstream state keys backends by `(backend_id,
3856        // address)`, so the address alone already keeps them apart; the
3857        // address-suffixed id keeps logs, metrics and removals unambiguous.
3858        debug_assert_eq!(backends.len(), addresses.len());
3859        debug_assert!(
3860            backends
3861                .iter()
3862                .map(|backend| backend.backend_id.as_deref())
3863                .collect::<HashSet<_>>()
3864                .len()
3865                == backends.len(),
3866            "resolved backends must carry distinct ids"
3867        );
3868        Ok(backends)
3869    }
3870}
3871
3872impl FileClusterConfig {
3873    pub fn to_cluster_config(
3874        self,
3875        cluster_id: &str,
3876        expect_proxy: &HashSet<SocketAddr>,
3877    ) -> Result<ClusterConfig, ConfigError> {
3878        self.to_cluster_config_with(cluster_id, expect_proxy, Some(&system_resolver))
3879    }
3880
3881    /// [`FileClusterConfig::to_cluster_config`] with the backend resolver
3882    /// chosen by the caller. `None` leaves every `host:port` entry out and
3883    /// keeps only socket-address literals: a control command reads the
3884    /// configuration for its scalar settings and must not depend on DNS.
3885    pub fn to_cluster_config_with(
3886        self,
3887        cluster_id: &str,
3888        expect_proxy: &HashSet<SocketAddr>,
3889        resolver: Option<&BackendResolver<'_>>,
3890    ) -> Result<ClusterConfig, ConfigError> {
3891        // PRE: every frontend that converts cleanly must survive into the built
3892        // cluster — no frontend is silently dropped during conversion.
3893        let requested_frontend_count = self.frontends.len();
3894        validate_affinity_key(
3895            self.affinity_header.as_deref(),
3896            self.affinity_cookie.as_deref(),
3897        )
3898        .map_err(|reason| ConfigError::InvalidAffinityKey {
3899            cluster_id: cluster_id.to_owned(),
3900            reason,
3901        })?;
3902        validate_shuffle_sharding(
3903            self.shard_percent,
3904            self.shard_min_backends,
3905            self.shard_mode.map(|mode| mode as i32),
3906        )
3907        .map_err(|reason| ConfigError::InvalidShuffleSharding {
3908            cluster_id: cluster_id.to_owned(),
3909            reason,
3910        })?;
3911        if let Some(attempts) = self.max_connection_attempts {
3912            validate_max_connection_attempts(attempts).map_err(|reason| {
3913                ConfigError::InvalidMaxConnectionAttempts {
3914                    cluster_id: Some(cluster_id.to_owned()),
3915                    value: attempts,
3916                    reason,
3917                }
3918            })?;
3919        }
3920        let mut backends = Vec::with_capacity(self.backends.len());
3921        for (index, backend) in self.backends.iter().enumerate() {
3922            match resolver {
3923                Some(resolver) => backends.extend(backend.resolve(cluster_id, index, resolver)?),
3924                None if backend.address.parse::<SocketAddr>().is_ok() => {
3925                    backends.extend(backend.resolve(cluster_id, index, &system_resolver)?)
3926                }
3927                None => {}
3928            }
3929        }
3930        // POST: every file entry yields at least one backend when resolving.
3931        debug_assert!(resolver.is_none() || backends.len() >= self.backends.len());
3932        let (health_check, health_check_header_files) = match self
3933            .health_check
3934            .as_ref()
3935            .map(FileHealthCheckConfig::to_parts)
3936            .transpose()
3937            .map_err(|reason| ConfigError::InvalidHealthCheck {
3938                cluster_id: cluster_id.to_owned(),
3939                reason,
3940            })? {
3941            Some((config, files)) => (Some(config), files),
3942            None => (None, Vec::new()),
3943        };
3944        validate_backend_unix_paths(cluster_id, &backends)?;
3945        // The file entries, not only the expanded backends: an entry DNS did
3946        // not expand (no resolver) still names a `unix_path`.
3947        if health_check.as_ref().is_some_and(|hc| hc.port.is_some())
3948            && self.backends.iter().any(|b| b.unix_path.is_some())
3949        {
3950            return Err(ConfigError::InvalidHealthCheck {
3951                cluster_id: cluster_id.to_owned(),
3952                reason: HEALTH_CHECK_PORT_UNIX_REFUSED,
3953            });
3954        }
3955        match self.protocol {
3956            FileClusterProtocolConfig::Tcp => {
3957                // A TCP cluster has no request to read a header or a cookie
3958                // from: it always keys on the source IP. Refused rather than
3959                // ignored, so the file never claims a key the proxy does not use.
3960                if self.affinity_header.is_some() || self.affinity_cookie.is_some() {
3961                    return Err(ConfigError::InvalidAffinityKey {
3962                        cluster_id: cluster_id.to_owned(),
3963                        reason: "a TCP cluster always keys on the client source IP: \
3964                                 affinity_header and affinity_cookie apply to HTTP clusters only",
3965                    });
3966                }
3967                let mut has_expect_proxy = None;
3968                let mut frontends = Vec::new();
3969                for f in self.frontends {
3970                    if expect_proxy.contains(&f.address) {
3971                        match has_expect_proxy {
3972                            Some(true) => {}
3973                            Some(false) => {
3974                                return Err(ConfigError::Incompatible {
3975                                    object: ObjectKind::Cluster,
3976                                    id: cluster_id.to_owned(),
3977                                    kind: IncompatibilityKind::ProxyProtocol,
3978                                });
3979                            }
3980                            None => has_expect_proxy = Some(true),
3981                        }
3982                    } else {
3983                        match has_expect_proxy {
3984                            Some(false) => {}
3985                            Some(true) => {
3986                                return Err(ConfigError::Incompatible {
3987                                    object: ObjectKind::Cluster,
3988                                    id: cluster_id.to_owned(),
3989                                    kind: IncompatibilityKind::ProxyProtocol,
3990                                });
3991                            }
3992                            None => has_expect_proxy = Some(false),
3993                        }
3994                    }
3995                    let tcp_frontend = f.to_tcp_front()?;
3996                    frontends.push(tcp_frontend);
3997                }
3998
3999                let send_proxy = self.send_proxy.unwrap_or(false);
4000                let expect_proxy = has_expect_proxy.unwrap_or(false);
4001                let proxy_protocol = match (send_proxy, expect_proxy) {
4002                    (true, true) => Some(ProxyProtocolConfig::RelayHeader),
4003                    (true, false) => Some(ProxyProtocolConfig::SendHeader),
4004                    (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
4005                    _ => None,
4006                };
4007
4008                let answers = match self.answers.as_ref() {
4009                    Some(map) => load_answers(map)?,
4010                    None => BTreeMap::new(),
4011                };
4012
4013                let udp = self.udp.as_ref().map(|u| u.to_proto());
4014                // POST: every requested frontend converted (none dropped), and
4015                // the resolved proxy-protocol mode is the documented function of
4016                // the (send, expect) pair — expect-only must never resolve to a
4017                // send-header mode and vice versa, which would corrupt the wire
4018                // framing.
4019                debug_assert_eq!(
4020                    frontends.len(),
4021                    requested_frontend_count,
4022                    "every TCP frontend must survive conversion"
4023                );
4024                debug_assert_eq!(
4025                    proxy_protocol,
4026                    match (send_proxy, expect_proxy) {
4027                        (true, true) => Some(ProxyProtocolConfig::RelayHeader),
4028                        (true, false) => Some(ProxyProtocolConfig::SendHeader),
4029                        (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
4030                        (false, false) => None,
4031                    },
4032                    "proxy_protocol must be the (send, expect) function"
4033                );
4034
4035                Ok(ClusterConfig::Tcp(TcpClusterConfig {
4036                    cluster_id: cluster_id.to_string(),
4037                    frontends,
4038                    backends,
4039                    proxy_protocol,
4040                    load_balancing: self.load_balancing,
4041                    load_metric: self.load_metric,
4042                    answers,
4043                    https_redirect_port: self.https_redirect_port,
4044                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
4045                    www_authenticate: self.www_authenticate,
4046                    max_connections_per_ip: self.max_connections_per_ip,
4047                    max_connections_per_subnet: self.max_connections_per_subnet,
4048                    retry_after: self.retry_after,
4049                    health_check,
4050                    health_check_header_files,
4051                    udp,
4052                    shard_percent: self.shard_percent,
4053                    shard_min_backends: self.shard_min_backends,
4054                    shard_mode: self.shard_mode,
4055                    max_connection_attempts: self.max_connection_attempts,
4056                }))
4057            }
4058            FileClusterProtocolConfig::Http => {
4059                let mut frontends = Vec::new();
4060                for frontend in self.frontends {
4061                    let http_frontend = frontend.to_http_front(cluster_id)?;
4062                    frontends.push(http_frontend);
4063                }
4064
4065                let answer_503 = self.answer_503.as_ref().and_then(|path| {
4066                    Config::load_file(path)
4067                        .map_err(|e| {
4068                            error!("cannot load 503 error page at path '{}': {:?}", path, e);
4069                            e
4070                        })
4071                        .ok()
4072                });
4073
4074                let answers = match self.answers.as_ref() {
4075                    Some(map) => load_answers(map)?,
4076                    None => BTreeMap::new(),
4077                };
4078
4079                let udp = self.udp.as_ref().map(|u| u.to_proto());
4080                // POST: every requested HTTP frontend converted — none dropped
4081                // (a dropped frontend would silently stop routing a hostname).
4082                debug_assert_eq!(
4083                    frontends.len(),
4084                    requested_frontend_count,
4085                    "every HTTP frontend must survive conversion"
4086                );
4087
4088                Ok(ClusterConfig::Http(HttpClusterConfig {
4089                    cluster_id: cluster_id.to_string(),
4090                    frontends,
4091                    backends,
4092                    sticky_session: self.sticky_session.unwrap_or(false),
4093                    https_redirect: self.https_redirect.unwrap_or(false),
4094                    load_balancing: self.load_balancing,
4095                    load_metric: self.load_metric,
4096                    answer_503,
4097                    http2: self.http2,
4098                    answers,
4099                    https_redirect_port: self.https_redirect_port,
4100                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
4101                    www_authenticate: self.www_authenticate,
4102                    max_connections_per_ip: self.max_connections_per_ip,
4103                    max_connections_per_subnet: self.max_connections_per_subnet,
4104                    retry_after: self.retry_after,
4105                    health_check,
4106                    health_check_header_files,
4107                    udp,
4108                    affinity_header: self.affinity_header,
4109                    affinity_cookie: self.affinity_cookie,
4110                    shard_percent: self.shard_percent,
4111                    shard_min_backends: self.shard_min_backends,
4112                    shard_mode: self.shard_mode,
4113                    max_connection_attempts: self.max_connection_attempts,
4114                }))
4115            }
4116        }
4117    }
4118}
4119
4120#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4121#[serde(deny_unknown_fields)]
4122pub struct HttpFrontendConfig {
4123    pub address: SocketAddr,
4124    pub hostname: String,
4125    pub path: PathRule,
4126    pub method: Option<String>,
4127    pub certificate: Option<String>,
4128    pub key: Option<String>,
4129    pub certificate_chain: Option<Vec<String>>,
4130    #[serde(default)]
4131    pub tls_versions: Vec<TlsVersion>,
4132    #[serde(default)]
4133    pub position: RulePosition,
4134    pub tags: Option<BTreeMap<String, String>>,
4135    /// Resolved redirect policy. `None` keeps the proto-default `FORWARD`.
4136    #[serde(default)]
4137    pub redirect: Option<RedirectPolicy>,
4138    /// Resolved redirect scheme. `None` keeps the proto-default `USE_SAME`.
4139    #[serde(default)]
4140    pub redirect_scheme: Option<RedirectScheme>,
4141    #[serde(default)]
4142    pub redirect_template: Option<String>,
4143    #[serde(default)]
4144    pub rewrite_host: Option<String>,
4145    #[serde(default)]
4146    pub rewrite_path: Option<String>,
4147    #[serde(default)]
4148    pub rewrite_port: Option<u32>,
4149    #[serde(default)]
4150    pub required_auth: Option<bool>,
4151    /// Header mutations applied to requests and/or responses passing through
4152    /// this frontend. Empty by default.
4153    #[serde(default)]
4154    pub headers: Vec<Header>,
4155    /// Resolved per-frontend HSTS (RFC 6797) policy. `None` means inherit
4156    /// the listener default at frontend-add time in the worker.
4157    #[serde(default)]
4158    pub hsts: Option<HstsConfig>,
4159    /// Header predicates the request must all satisfy for this frontend to
4160    /// match (sozu-proxy/sozu#1253). Empty by default.
4161    #[serde(default)]
4162    pub header_predicates: Vec<HeaderPredicate>,
4163}
4164
4165impl fmt::Debug for HttpFrontendConfig {
4166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4167        let certificate = self.certificate.as_ref().map(|_| "[redacted]");
4168        let certificate_len = self.certificate.as_ref().map(String::len);
4169        let key = self.key.as_ref().map(|_| "[redacted]");
4170        let key_len = self.key.as_ref().map(String::len);
4171        let certificate_chain = self.certificate_chain.as_ref().map(|_| "[redacted]");
4172        let certificate_chain_count = self.certificate_chain.as_ref().map(Vec::len);
4173        let certificate_chain_len = self.certificate_chain.as_ref().map(|chain| {
4174            chain
4175                .iter()
4176                .map(String::len)
4177                .fold(0usize, usize::saturating_add)
4178        });
4179        let method_len = self.method.as_ref().map(String::len);
4180        let redirect_template_len = self.redirect_template.as_ref().map(String::len);
4181        let rewrite_host_len = self.rewrite_host.as_ref().map(String::len);
4182        let rewrite_path_len = self.rewrite_path.as_ref().map(String::len);
4183
4184        f.debug_struct("HttpFrontendConfig")
4185            .field("address", &self.address)
4186            .field("hostname_len", &self.hostname.len())
4187            .field("path_kind", &self.path.kind)
4188            .field("path_len", &self.path.value.len())
4189            .field("method_len", &method_len)
4190            .field("certificate", &certificate)
4191            .field("certificate_len", &certificate_len)
4192            .field("key", &key)
4193            .field("key_len", &key_len)
4194            .field("certificate_chain", &certificate_chain)
4195            .field("certificate_chain_count", &certificate_chain_count)
4196            .field("certificate_chain_len", &certificate_chain_len)
4197            .field("tls_versions_count", &self.tls_versions.len())
4198            .field("position", &self.position)
4199            .field(
4200                "tags_count",
4201                &self.tags.as_ref().map(BTreeMap::len).unwrap_or_default(),
4202            )
4203            .field("redirect", &self.redirect)
4204            .field("redirect_scheme", &self.redirect_scheme)
4205            .field("redirect_template_len", &redirect_template_len)
4206            .field("rewrite_host_len", &rewrite_host_len)
4207            .field("rewrite_path_len", &rewrite_path_len)
4208            .field("rewrite_port", &self.rewrite_port)
4209            .field("required_auth", &self.required_auth)
4210            .field("headers_count", &self.headers.len())
4211            .field("hsts", &self.hsts)
4212            .field("header_predicates_count", &self.header_predicates.len())
4213            .finish()
4214    }
4215}
4216
4217impl HttpFrontendConfig {
4218    pub fn generate_requests(&self, cluster_id: &str) -> Vec<Request> {
4219        let mut v = Vec::new();
4220
4221        let tags = self.tags.clone().unwrap_or_default();
4222
4223        if self.key.is_some() && self.certificate.is_some() {
4224            v.push(
4225                RequestType::AddCertificate(AddCertificate {
4226                    address: self.address.into(),
4227                    certificate: CertificateAndKey {
4228                        key: self.key.clone().unwrap(),
4229                        certificate: self.certificate.clone().unwrap(),
4230                        certificate_chain: self.certificate_chain.clone().unwrap_or_default(),
4231                        versions: self.tls_versions.iter().map(|v| *v as i32).collect(),
4232                        // This field is used to override the certificate subject and san, we should not set it when
4233                        // loading the configuration, as we may provide a wildcard certificate for a specific domain.
4234                        // As a result, we will reject legit traffic for others domains as the certificate resolver will
4235                        // not load twice the same certificate and then do not register the certificate for others domains.
4236                        names: vec![],
4237                    },
4238                    expired_at: None,
4239                })
4240                .into(),
4241            );
4242
4243            v.push(
4244                RequestType::add_https_frontend(RequestHttpFrontend {
4245                    cluster_id: Some(cluster_id.to_string()),
4246                    address: self.address.into(),
4247                    hostname: self.hostname.clone(),
4248                    path: self.path.clone(),
4249                    method: self.method.clone(),
4250                    position: self.position.into(),
4251                    tags,
4252                    redirect: self.redirect.map(|r| r as i32),
4253                    required_auth: self.required_auth,
4254                    redirect_scheme: self.redirect_scheme.map(|s| s as i32),
4255                    redirect_template: self.redirect_template.clone(),
4256                    rewrite_host: self.rewrite_host.clone(),
4257                    rewrite_path: self.rewrite_path.clone(),
4258                    rewrite_port: self.rewrite_port,
4259                    headers: self.headers.clone(),
4260                    hsts: self.hsts,
4261                    header_predicates: self.header_predicates.clone(),
4262                })
4263                .into(),
4264            );
4265        } else {
4266            //create the front both for HTTP and HTTPS if possible
4267            v.push(
4268                RequestType::add_http_frontend(RequestHttpFrontend {
4269                    cluster_id: Some(cluster_id.to_string()),
4270                    address: self.address.into(),
4271                    hostname: self.hostname.clone(),
4272                    path: self.path.clone(),
4273                    method: self.method.clone(),
4274                    position: self.position.into(),
4275                    tags,
4276                    redirect: self.redirect.map(|r| r as i32),
4277                    required_auth: self.required_auth,
4278                    redirect_scheme: self.redirect_scheme.map(|s| s as i32),
4279                    redirect_template: self.redirect_template.clone(),
4280                    rewrite_host: self.rewrite_host.clone(),
4281                    rewrite_path: self.rewrite_path.clone(),
4282                    rewrite_port: self.rewrite_port,
4283                    headers: self.headers.clone(),
4284                    hsts: self.hsts,
4285                    header_predicates: self.header_predicates.clone(),
4286                })
4287                .into(),
4288            );
4289        }
4290
4291        v
4292    }
4293}
4294
4295#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4296#[serde(deny_unknown_fields)]
4297pub struct HttpClusterConfig {
4298    pub cluster_id: String,
4299    pub frontends: Vec<HttpFrontendConfig>,
4300    pub backends: Vec<BackendConfig>,
4301    pub sticky_session: bool,
4302    pub https_redirect: bool,
4303    pub load_balancing: LoadBalancingAlgorithms,
4304    pub load_metric: Option<LoadMetric>,
4305    pub answer_503: Option<String>,
4306    pub http2: Option<bool>,
4307    /// Per-status template body map (already loaded from disk). Maps to
4308    /// the proto [`Cluster::answers`] field.
4309    #[serde(default)]
4310    pub answers: BTreeMap<String, String>,
4311    #[serde(default)]
4312    pub https_redirect_port: Option<u32>,
4313    #[serde(default)]
4314    pub authorized_hashes: Vec<String>,
4315    #[serde(default)]
4316    pub www_authenticate: Option<String>,
4317    /// Per-cluster override of the global `max_connections_per_ip`. See
4318    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
4319    #[serde(default)]
4320    pub max_connections_per_ip: Option<u64>,
4321    /// Per-cluster override of the global `max_connections_per_subnet`.
4322    /// See [`FileClusterConfig::max_connections_per_subnet`] for
4323    /// semantics: an independent second cap, not a replacement for the
4324    /// per-IP one.
4325    #[serde(default)]
4326    pub max_connections_per_subnet: Option<u64>,
4327    /// Per-cluster override of the global `retry_after` HTTP-429 header
4328    /// value (seconds). See [`FileClusterConfig::retry_after`].
4329    #[serde(default)]
4330    pub retry_after: Option<u32>,
4331    /// Optional HTTP health-check configuration. The probe wire format
4332    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
4333    /// (h2c) when true.
4334    #[serde(default)]
4335    pub health_check: Option<HealthCheckConfig>,
4336    /// Where each header named by `health_check` reads its value from, in the
4337    /// same order. References, never values: they are kept for the protected
4338    /// activation and never copied into an `AddCluster` order.
4339    #[serde(default)]
4340    pub health_check_header_files: Vec<FileHealthCheckHeader>,
4341    /// Optional UDP-specific cluster configuration. Always `None` for HTTP
4342    /// clusters; carried for shape uniformity with the proto [`Cluster`].
4343    #[serde(default)]
4344    pub udp: Option<UdpClusterConfig>,
4345    /// Request header keying `HRW`/`MAGLEV` selection. See
4346    /// [`FileClusterConfig::affinity_header`].
4347    #[serde(default)]
4348    pub affinity_header: Option<String>,
4349    /// Request cookie keying `HRW`/`MAGLEV` selection. See
4350    /// [`FileClusterConfig::affinity_cookie`].
4351    #[serde(default)]
4352    pub affinity_cookie: Option<String>,
4353    /// Shuffle-sharding share of the backends. See
4354    /// [`FileClusterConfig::shard_percent`].
4355    #[serde(default)]
4356    pub shard_percent: Option<u32>,
4357    /// See [`FileClusterConfig::shard_min_backends`].
4358    #[serde(default)]
4359    pub shard_min_backends: Option<u32>,
4360    /// See [`FileClusterConfig::shard_mode`].
4361    #[serde(default)]
4362    pub shard_mode: Option<ShardMode>,
4363    /// See [`FileClusterConfig::max_connection_attempts`].
4364    #[serde(default)]
4365    pub max_connection_attempts: Option<u32>,
4366}
4367
4368impl HttpClusterConfig {
4369    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
4370        // A header name without its value must not reach a worker, and the
4371        // value-file references do not either: until the protected activation
4372        // exists, refuse before any order is built.
4373        if !self.health_check_header_files.is_empty() {
4374            return Err(ConfigError::InvalidHealthCheck {
4375                cluster_id: self.cluster_id.clone(),
4376                reason: HEALTH_CHECK_VALUE_FILE_UNSUPPORTED,
4377            });
4378        }
4379        let mut v: Vec<Request> = vec![
4380            RequestType::AddCluster(Cluster {
4381                cluster_id: self.cluster_id.clone(),
4382                sticky_session: self.sticky_session,
4383                https_redirect: self.https_redirect,
4384                proxy_protocol: None,
4385                load_balancing: self.load_balancing as i32,
4386                answer_503: self.answer_503.clone(),
4387                load_metric: self.load_metric.map(|s| s as i32),
4388                http2: self.http2,
4389                answers: self.answers.clone(),
4390                https_redirect_port: self.https_redirect_port,
4391                authorized_hashes: self.authorized_hashes.clone(),
4392                www_authenticate: self.www_authenticate.clone(),
4393                max_connections_per_ip: self.max_connections_per_ip,
4394                max_connections_per_subnet: self.max_connections_per_subnet,
4395                retry_after: self.retry_after,
4396                health_check: self.health_check.clone(),
4397                udp: self.udp.clone(),
4398                affinity_header: self.affinity_header.clone(),
4399                affinity_cookie: self.affinity_cookie.clone(),
4400                shard_percent: self.shard_percent,
4401                shard_min_backends: self.shard_min_backends,
4402                shard_mode: self.shard_mode.map(|mode| mode as i32),
4403                max_connection_attempts: self.max_connection_attempts,
4404            })
4405            .into(),
4406        ];
4407
4408        for frontend in &self.frontends {
4409            let mut orders = frontend.generate_requests(&self.cluster_id);
4410            v.append(&mut orders);
4411        }
4412
4413        for (backend_count, backend) in self.backends.iter().enumerate() {
4414            let load_balancing_parameters = Some(LoadBalancingParams {
4415                weight: backend.weight.unwrap_or(100) as i32,
4416            });
4417
4418            v.push(
4419                RequestType::add_backend(AddBackend {
4420                    cluster_id: self.cluster_id.clone(),
4421                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
4422                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
4423                    }),
4424                    address: backend.address.into(),
4425                    load_balancing_parameters,
4426                    sticky_id: backend.sticky_id.clone(),
4427                    backup: backend.backup,
4428                    unix_path: backend.unix_path.clone(),
4429                })
4430                .into(),
4431            );
4432        }
4433
4434        // POST: the order stream begins with exactly one AddCluster and emits
4435        // exactly one AddBackend per configured backend — a missing AddCluster
4436        // would orphan every backend, and a miscounted backend set would
4437        // silently drop or duplicate a backend registration.
4438        debug_assert!(
4439            matches!(
4440                v.first().and_then(|r| r.request_type.as_ref()),
4441                Some(RequestType::AddCluster(_))
4442            ),
4443            "HTTP cluster orders must lead with an AddCluster"
4444        );
4445        debug_assert_eq!(
4446            v.iter()
4447                .filter(|r| matches!(
4448                    r.request_type,
4449                    Some(RequestType::AddBackend(_)) | Some(RequestType::AddUnixBackend(_))
4450                ))
4451                .count(),
4452            self.backends.len(),
4453            "one AddBackend order per configured backend"
4454        );
4455        Ok(v)
4456    }
4457}
4458
4459#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4460pub struct TcpFrontendConfig {
4461    pub address: SocketAddr,
4462    pub tags: Option<BTreeMap<String, String>>,
4463    /// `true` when this frontend's address resolves to a `protocol = "udp"`
4464    /// listener. Resolved at config-load in `ConfigBuilder::populate_clusters`
4465    /// from `known_addresses`; selects `AddUdpFrontend` over `AddTcpFrontend`
4466    /// in [`TcpClusterConfig::generate_requests`]. A UDP cluster is declared as
4467    /// a `protocol = "tcp"` cluster whose frontends point at UDP listeners and
4468    /// whose datagram knobs live under `[clusters.<id>.udp]`.
4469    #[serde(default)]
4470    pub udp: bool,
4471    /// SNI hostname this frontend matches, validated and normalized by
4472    /// [`FileClusterFrontendConfig::to_tcp_front`] (sozu-proxy/sozu#1279).
4473    /// `None` matches regardless of SNI (a raw-TCP fallback).
4474    #[serde(default)]
4475    pub sni: Option<String>,
4476    /// ALPN protocol names this frontend matches; empty is the catch-all
4477    /// for its `sni` on this listener.
4478    #[serde(default)]
4479    pub alpn: Vec<String>,
4480}
4481
4482#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4483pub struct TcpClusterConfig {
4484    pub cluster_id: String,
4485    pub frontends: Vec<TcpFrontendConfig>,
4486    pub backends: Vec<BackendConfig>,
4487    #[serde(default)]
4488    pub proxy_protocol: Option<ProxyProtocolConfig>,
4489    pub load_balancing: LoadBalancingAlgorithms,
4490    pub load_metric: Option<LoadMetric>,
4491    /// Per-status template body map (already loaded from disk). Even
4492    /// though TCP clusters do not emit HTTP responses, the field is
4493    /// carried for shape uniformity with [`HttpClusterConfig`].
4494    #[serde(default)]
4495    pub answers: BTreeMap<String, String>,
4496    #[serde(default)]
4497    pub https_redirect_port: Option<u32>,
4498    #[serde(default)]
4499    pub authorized_hashes: Vec<String>,
4500    #[serde(default)]
4501    pub www_authenticate: Option<String>,
4502    /// Per-cluster override of the global `max_connections_per_ip`. See
4503    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
4504    #[serde(default)]
4505    pub max_connections_per_ip: Option<u64>,
4506    /// Per-cluster override of the global `max_connections_per_subnet`.
4507    /// See [`FileClusterConfig::max_connections_per_subnet`] for
4508    /// semantics: an independent second cap, not a replacement for the
4509    /// per-IP one.
4510    #[serde(default)]
4511    pub max_connections_per_subnet: Option<u64>,
4512    /// Per-cluster override of the global `retry_after`. TCP listeners
4513    /// never emit `Retry-After`; the field is carried for shape
4514    /// uniformity with [`HttpClusterConfig`].
4515    #[serde(default)]
4516    pub retry_after: Option<u32>,
4517    /// Optional HTTP health-check configuration. TCP clusters carry this
4518    /// field for shape uniformity with [`HttpClusterConfig`]; probes are
4519    /// HTTP/1.1 only and TCP-only backends should leave this absent.
4520    #[serde(default)]
4521    pub health_check: Option<HealthCheckConfig>,
4522    /// Where each header named by `health_check` reads its value from, in the
4523    /// same order. References, never values: they are kept for the protected
4524    /// activation and never copied into an `AddCluster` order.
4525    #[serde(default)]
4526    pub health_check_header_files: Vec<FileHealthCheckHeader>,
4527    /// Optional UDP-specific cluster configuration, parsed from a
4528    /// `[clusters.<id>.udp]` block on this cluster.
4529    #[serde(default)]
4530    pub udp: Option<UdpClusterConfig>,
4531    /// Shuffle-sharding share of the backends. See
4532    /// [`FileClusterConfig::shard_percent`].
4533    #[serde(default)]
4534    pub shard_percent: Option<u32>,
4535    /// See [`FileClusterConfig::shard_min_backends`].
4536    #[serde(default)]
4537    pub shard_min_backends: Option<u32>,
4538    /// See [`FileClusterConfig::shard_mode`].
4539    #[serde(default)]
4540    pub shard_mode: Option<ShardMode>,
4541    /// See [`FileClusterConfig::max_connection_attempts`].
4542    #[serde(default)]
4543    pub max_connection_attempts: Option<u32>,
4544}
4545
4546impl TcpClusterConfig {
4547    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
4548        // A header name without its value must not reach a worker, and the
4549        // value-file references do not either: until the protected activation
4550        // exists, refuse before any order is built.
4551        if !self.health_check_header_files.is_empty() {
4552            return Err(ConfigError::InvalidHealthCheck {
4553                cluster_id: self.cluster_id.clone(),
4554                reason: HEALTH_CHECK_VALUE_FILE_UNSUPPORTED,
4555            });
4556        }
4557        let mut v: Vec<Request> = vec![
4558            RequestType::AddCluster(Cluster {
4559                cluster_id: self.cluster_id.clone(),
4560                sticky_session: false,
4561                https_redirect: false,
4562                proxy_protocol: self.proxy_protocol.map(|s| s as i32),
4563                load_balancing: self.load_balancing as i32,
4564                load_metric: self.load_metric.map(|s| s as i32),
4565                answer_503: None,
4566                http2: None,
4567                answers: self.answers.clone(),
4568                https_redirect_port: self.https_redirect_port,
4569                authorized_hashes: self.authorized_hashes.clone(),
4570                www_authenticate: self.www_authenticate.clone(),
4571                max_connections_per_ip: self.max_connections_per_ip,
4572                max_connections_per_subnet: self.max_connections_per_subnet,
4573                retry_after: self.retry_after,
4574                health_check: self.health_check.clone(),
4575                udp: self.udp.clone(),
4576                // A TCP cluster always keys on the source IP.
4577                affinity_header: None,
4578                affinity_cookie: None,
4579                shard_percent: self.shard_percent,
4580                shard_min_backends: self.shard_min_backends,
4581                shard_mode: self.shard_mode.map(|mode| mode as i32),
4582                max_connection_attempts: self.max_connection_attempts,
4583            })
4584            .into(),
4585        ];
4586
4587        for frontend in &self.frontends {
4588            // A frontend whose address resolves to a `protocol = "udp"`
4589            // listener (flagged in `populate_clusters`) is added as a UDP
4590            // frontend; all others stay TCP. Mixed TCP/UDP frontends on the
4591            // same cluster are supported.
4592            if frontend.udp {
4593                v.push(
4594                    RequestType::AddUdpFrontend(RequestUdpFrontend {
4595                        cluster_id: self.cluster_id.clone(),
4596                        address: frontend.address.into(),
4597                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
4598                    })
4599                    .into(),
4600                );
4601            } else {
4602                v.push(
4603                    RequestType::AddTcpFrontend(RequestTcpFrontend {
4604                        cluster_id: self.cluster_id.clone(),
4605                        address: frontend.address.into(),
4606                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
4607                        sni: frontend.sni.clone(),
4608                        alpn: frontend.alpn.clone(),
4609                    })
4610                    .into(),
4611                );
4612            }
4613        }
4614
4615        for (backend_count, backend) in self.backends.iter().enumerate() {
4616            let load_balancing_parameters = Some(LoadBalancingParams {
4617                weight: backend.weight.unwrap_or(100) as i32,
4618            });
4619
4620            v.push(
4621                RequestType::add_backend(AddBackend {
4622                    cluster_id: self.cluster_id.clone(),
4623                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
4624                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
4625                    }),
4626                    address: backend.address.into(),
4627                    load_balancing_parameters,
4628                    sticky_id: backend.sticky_id.clone(),
4629                    backup: backend.backup,
4630                    unix_path: backend.unix_path.clone(),
4631                })
4632                .into(),
4633            );
4634        }
4635
4636        // POST: the order stream leads with one AddCluster and emits exactly
4637        // one AddTcpFrontend per frontend and one AddBackend per backend — the
4638        // worker reconstructs the cluster topology solely from these counts.
4639        debug_assert!(
4640            matches!(
4641                v.first().and_then(|r| r.request_type.as_ref()),
4642                Some(RequestType::AddCluster(_))
4643            ),
4644            "TCP cluster orders must lead with an AddCluster"
4645        );
4646        debug_assert_eq!(
4647            v.iter()
4648                .filter(|r| matches!(
4649                    r.request_type,
4650                    Some(RequestType::AddTcpFrontend(_)) | Some(RequestType::AddUdpFrontend(_))
4651                ))
4652                .count(),
4653            self.frontends.len(),
4654            "one AddTcpFrontend or AddUdpFrontend order per configured frontend"
4655        );
4656        debug_assert_eq!(
4657            v.iter()
4658                .filter(|r| matches!(
4659                    r.request_type,
4660                    Some(RequestType::AddBackend(_)) | Some(RequestType::AddUnixBackend(_))
4661                ))
4662                .count(),
4663            self.backends.len(),
4664            "one AddBackend order per configured backend"
4665        );
4666        Ok(v)
4667    }
4668}
4669
4670#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4671pub enum ClusterConfig {
4672    Http(HttpClusterConfig),
4673    Tcp(TcpClusterConfig),
4674}
4675
4676impl ClusterConfig {
4677    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
4678        match *self {
4679            ClusterConfig::Http(ref http) => http.generate_requests(),
4680            ClusterConfig::Tcp(ref tcp) => tcp.generate_requests(),
4681        }
4682    }
4683}
4684
4685/// Parsed from the TOML config provided by the user.
4686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
4687pub struct FileConfig {
4688    pub command_socket: Option<String>,
4689    /// Permission bits of the unix command socket, as an octal string such
4690    /// as `"0660"` or `"660"`. `None` keeps [`DEFAULT_COMMAND_SOCKET_MODE`].
4691    /// Parsed by [`parse_command_socket_mode`] when the config is built.
4692    #[serde(default)]
4693    pub command_socket_mode: Option<String>,
4694    pub command_buffer_size: Option<u64>,
4695    pub max_command_buffer_size: Option<u64>,
4696    pub max_connections: Option<usize>,
4697    pub min_buffers: Option<u64>,
4698    pub max_buffers: Option<u64>,
4699    pub buffer_size: Option<u64>,
4700    /// Slab-entries-per-connection multiplier. `None` keeps the compile-time
4701    /// default of 4. Operator-visible escape hatch for fan-out topologies
4702    /// that exceed 4 backends per session — clamped to [2, 32] at load.
4703    #[serde(default)]
4704    pub slab_entries_per_connection: Option<u64>,
4705    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
4706    /// payload accepted by the worker's `mux::auth` module. Caps the
4707    /// per-failed-auth allocation so a hostile peer cannot force the worker
4708    /// to decode arbitrarily large tokens. RFC 7617 imposes no upper bound
4709    /// — defaults to 4096, which is well above the realistic
4710    /// `username:password` shape. Operators running hardened tenants can
4711    /// lower this to e.g. 256 or 512 to bound the allocation tighter.
4712    /// Values >= `buffer_size / 3` emit a warning at config-load time
4713    /// (the credential cap shouldn't dominate the per-frontend buffer).
4714    #[serde(default)]
4715    pub basic_auth_max_credential_bytes: Option<u64>,
4716    /// Default per-(cluster, source-IP) connection limit. `None` keeps
4717    /// `0` (unlimited). Each cluster may override via its own
4718    /// `max_connections_per_ip`. The source IP is taken from the parsed
4719    /// proxy-protocol header when present, else `peer_addr`. When the
4720    /// limit is reached, HTTP requests are answered with `429 Too Many
4721    /// Requests` (with optional `Retry-After`) and TCP sessions are
4722    /// closed gracefully without dialing the backend.
4723    #[serde(default)]
4724    pub max_connections_per_ip: Option<u64>,
4725    /// Default per-(cluster, source-SUBNET) connection limit. `None`
4726    /// keeps `0` (unlimited), which leaves this limiter inert and the
4727    /// per-IP behaviour strictly unchanged. It is a SECOND, independent
4728    /// counter beside `max_connections_per_ip`: the per-IP cap serves
4729    /// the API-gateway case, the per-subnet cap the denial-of-service
4730    /// case where an attacker holding a routed prefix takes a fresh
4731    /// address per connection (sozu-proxy/sozu#1270). Both gates are
4732    /// consulted and both must admit, so "10 per IP AND 100 per /64" is
4733    /// expressible. Each cluster may override via its own
4734    /// `max_connections_per_subnet`.
4735    #[serde(default)]
4736    pub max_connections_per_subnet: Option<u64>,
4737    /// IPv4 prefix length (bits, 0-32) used to derive the subnet key.
4738    /// `None` keeps 32, which masks nothing. `/24` is the recommended
4739    /// starting point. Out-of-range values are rejected at config-load
4740    /// time rather than clamped.
4741    #[serde(default)]
4742    pub subnet_ipv4_prefix: Option<u32>,
4743    /// IPv6 prefix length (bits, 0-128) used to derive the subnet key.
4744    /// `None` keeps 128, which masks nothing. `/56` is the recommended
4745    /// starting point. An IPv4-mapped IPv6 source is canonicalised to
4746    /// IPv4 first and masked with `subnet_ipv4_prefix` instead.
4747    #[serde(default)]
4748    pub subnet_ipv6_prefix: Option<u32>,
4749    /// Default `Retry-After` header value (seconds) sent on HTTP 429
4750    /// responses. `Some(0)` or `None` keeping the default `0` omits the
4751    /// header (rendering `Retry-After: 0` invites an immediate retry that
4752    /// defeats the limit). Per-cluster overrides apply for HTTP listeners
4753    /// only. TCP listeners ignore this value (no HTTP envelope).
4754    #[serde(default)]
4755    pub retry_after: Option<u32>,
4756    /// Backend connection attempt budget of every request: how many backend
4757    /// connections a request may try, the first included, before it is
4758    /// answered `503` (HTTP) or closed (TCP). `None` keeps
4759    /// [`DEFAULT_MAX_CONNECTION_ATTEMPTS`]. Each cluster may override it with
4760    /// its own `max_connection_attempts`. Out-of-range values (see
4761    /// [`validate_max_connection_attempts`]) are rejected at config load.
4762    #[serde(default)]
4763    pub max_connection_attempts: Option<u32>,
4764    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
4765    /// zero-copy direction (Linux only, `splice` feature). `None` keeps
4766    /// the kernel default (64 KiB). Applied via `fcntl(F_SETPIPE_SZ)`;
4767    /// the kernel rounds up to a page boundary and clamps at
4768    /// `/proc/sys/fs/pipe-max-size` (default 1 MiB unprivileged). The
4769    /// realised capacity is read back via `fcntl(F_GETPIPE_SZ)` and
4770    /// drives the per-call `len` for `splice_in`. Ignored on non-Linux
4771    /// targets and on builds without the `splice` feature.
4772    #[serde(default)]
4773    pub splice_pipe_capacity_bytes: Option<u64>,
4774    /// Optional UID allowlist for command-socket requests. `None` (default)
4775    /// preserves historical behaviour: any same-UID local process can
4776    /// invoke any verb. When set, requests whose `SO_PEERCRED` UID is not
4777    /// in the list are rejected. Use to restrict mutating verbs to a
4778    /// specific operator UID even when other same-UID daemons coexist
4779    /// (CI runners, monitoring).
4780    #[serde(default)]
4781    pub command_allowed_uids: Option<Vec<u32>>,
4782    pub saved_state: Option<String>,
4783    #[serde(default)]
4784    pub automatic_state_save: Option<bool>,
4785    pub log_level: Option<String>,
4786    pub log_target: Option<String>,
4787    #[serde(default)]
4788    pub log_colored: bool,
4789    /// Dedicated file path for the control-plane audit log. When set, every
4790    /// emitted `[AUDIT]` / `Command(...)` line is also appended to this file
4791    /// opened `O_APPEND | O_CREAT` with mode `0o640` (owner read+write,
4792    /// group read, world nothing) so operators can separate the audit trail
4793    /// from the main log stream and protect it with group-scoped ACLs /
4794    /// logrotate. Independent of the standard `log_target`. `None` keeps
4795    /// audit lines routed only through the standard logger.
4796    #[serde(default)]
4797    pub audit_logs_target: Option<String>,
4798    /// Dedicated file path for a JSON-encoded mirror of the audit log.
4799    /// One JSON object per line so SIEM pipelines (Wazuh, Elastic, Loki)
4800    /// ingest without bespoke parsers. Same `O_APPEND | O_CREAT | 0o640`
4801    /// as `audit_logs_target`. `None` disables the JSON mirror.
4802    #[serde(default)]
4803    pub audit_logs_json_target: Option<String>,
4804    #[serde(default)]
4805    pub access_logs_target: Option<String>,
4806    #[serde(default)]
4807    pub access_logs_format: Option<AccessLogFormat>,
4808    #[serde(default)]
4809    pub access_logs_colored: Option<bool>,
4810    pub worker_count: Option<u16>,
4811    pub worker_automatic_restart: Option<bool>,
4812    pub metrics: Option<MetricsConfig>,
4813    pub disable_cluster_metrics: Option<bool>,
4814    pub listeners: Option<Vec<ListenerBuilder>>,
4815    pub clusters: Option<HashMap<String, FileClusterConfig>>,
4816    pub handle_process_affinity: Option<bool>,
4817    pub ctl_command_timeout: Option<u64>,
4818    pub pid_file_path: Option<String>,
4819    pub activate_listeners: Option<bool>,
4820    #[serde(default)]
4821    pub front_timeout: Option<u32>,
4822    #[serde(default)]
4823    pub back_timeout: Option<u32>,
4824    #[serde(default)]
4825    pub connect_timeout: Option<u32>,
4826    #[serde(default)]
4827    pub zombie_check_interval: Option<u32>,
4828    #[serde(default)]
4829    pub accept_queue_timeout: Option<u32>,
4830    #[serde(default)]
4831    pub evict_on_queue_full: Option<bool>,
4832    #[serde(default)]
4833    pub request_timeout: Option<u32>,
4834    #[serde(default)]
4835    pub worker_timeout: Option<u32>,
4836}
4837
4838impl FileConfig {
4839    pub fn load_from_path(path: &str) -> Result<FileConfig, ConfigError> {
4840        let data = Config::load_file(path)?;
4841
4842        let config: FileConfig = match toml::from_str(&data) {
4843            Ok(config) => config,
4844            Err(e) => {
4845                return Err(ConfigError::DeserializeToml(toml_error_diagnostic(
4846                    path, &e,
4847                )));
4848            }
4849        };
4850
4851        let mut reserved_address: HashSet<ListenerKey> = HashSet::new();
4852
4853        if let Some(listeners) = config.listeners.as_ref() {
4854            for listener in listeners.iter() {
4855                let key = ListenerKey::new(listener.address, listener.interface.as_deref());
4856                if reserved_address.contains(&key) {
4857                    return Err(ConfigError::ListenerAlreadyDeclared(key));
4858                }
4859                reserved_address.insert(key);
4860            }
4861        }
4862
4863        //FIXME: verify how clusters and listeners share addresses
4864        /*
4865        if let Some(ref clusters) = config.clusters {
4866          for (key, cluster) in clusters.iter() {
4867            if let (Some(address), Some(port)) = (cluster.ip_address.clone(), cluster.port) {
4868              let addr = (address, port);
4869              if reserved_address.contains(&addr) {
4870                println!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
4871                  key, addr.0, addr.1);
4872                return Err(Error::new(
4873                  ErrorKind::InvalidData,
4874                  format!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
4875                    key, addr.0, addr.1)));
4876              } else {
4877                reserved_address.insert(addr.clone());
4878              }
4879            }
4880          }
4881        }
4882        */
4883
4884        Ok(config)
4885    }
4886}
4887
4888/// A builder that converts [FileConfig] to [Config]
4889pub struct ConfigBuilder {
4890    file: FileConfig,
4891    known_addresses: HashMap<SocketAddr, ListenerProtocol>,
4892    expect_proxy_addresses: HashSet<SocketAddr>,
4893    /// `false` skips the resolution of `host:port` backends, see
4894    /// [`ConfigBuilder::without_backend_resolution`]
4895    resolve_backends: bool,
4896    built: Config,
4897}
4898
4899impl ConfigBuilder {
4900    /// starts building a [Config] with values from a [FileConfig], or defaults.
4901    ///
4902    /// please provide a config path, usefull for rebuilding the config later.
4903    pub fn new<S>(file_config: FileConfig, config_path: S) -> Self
4904    where
4905        S: ToString,
4906    {
4907        let built = Config {
4908            accept_queue_timeout: file_config
4909                .accept_queue_timeout
4910                .unwrap_or(DEFAULT_ACCEPT_QUEUE_TIMEOUT),
4911            evict_on_queue_full: file_config
4912                .evict_on_queue_full
4913                .unwrap_or(DEFAULT_EVICT_ON_QUEUE_FULL),
4914            activate_listeners: file_config.activate_listeners.unwrap_or(true),
4915            automatic_state_save: file_config
4916                .automatic_state_save
4917                .unwrap_or(DEFAULT_AUTOMATIC_STATE_SAVE),
4918            back_timeout: file_config.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
4919            buffer_size: file_config.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE),
4920            command_buffer_size: file_config
4921                .command_buffer_size
4922                .unwrap_or(DEFAULT_COMMAND_BUFFER_SIZE),
4923            config_path: config_path.to_string(),
4924            connect_timeout: file_config
4925                .connect_timeout
4926                .unwrap_or(DEFAULT_CONNECT_TIMEOUT),
4927            ctl_command_timeout: file_config.ctl_command_timeout.unwrap_or(1_000),
4928            front_timeout: file_config.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
4929            handle_process_affinity: file_config.handle_process_affinity.unwrap_or(false),
4930            access_logs_target: file_config.access_logs_target.clone(),
4931            audit_logs_target: file_config.audit_logs_target.clone(),
4932            audit_logs_json_target: file_config.audit_logs_json_target.clone(),
4933            access_logs_format: file_config.access_logs_format.clone(),
4934            access_logs_colored: file_config.access_logs_colored,
4935            log_level: file_config
4936                .log_level
4937                .clone()
4938                .unwrap_or_else(|| String::from("info")),
4939            log_target: file_config
4940                .log_target
4941                .clone()
4942                .unwrap_or_else(|| String::from("stdout")),
4943            log_colored: file_config.log_colored,
4944            max_buffers: file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
4945            max_command_buffer_size: file_config
4946                .max_command_buffer_size
4947                .unwrap_or(DEFAULT_MAX_COMMAND_BUFFER_SIZE),
4948            max_connections: file_config
4949                .max_connections
4950                .unwrap_or(DEFAULT_MAX_CONNECTIONS),
4951            metrics: file_config.metrics.clone(),
4952            disable_cluster_metrics: file_config
4953                .disable_cluster_metrics
4954                .unwrap_or(DEFAULT_DISABLE_CLUSTER_METRICS),
4955            min_buffers: std::cmp::min(
4956                file_config.min_buffers.unwrap_or(DEFAULT_MIN_BUFFERS),
4957                file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
4958            ),
4959            pid_file_path: file_config.pid_file_path.clone(),
4960            request_timeout: file_config
4961                .request_timeout
4962                .unwrap_or(DEFAULT_REQUEST_TIMEOUT),
4963            saved_state: file_config.saved_state.clone(),
4964            worker_automatic_restart: file_config
4965                .worker_automatic_restart
4966                .unwrap_or(DEFAULT_WORKER_AUTOMATIC_RESTART),
4967            worker_count: file_config.worker_count.unwrap_or(DEFAULT_WORKER_COUNT),
4968            zombie_check_interval: file_config
4969                .zombie_check_interval
4970                .unwrap_or(DEFAULT_ZOMBIE_CHECK_INTERVAL),
4971            worker_timeout: file_config.worker_timeout.unwrap_or(DEFAULT_WORKER_TIMEOUT),
4972            slab_entries_per_connection: file_config.slab_entries_per_connection.map(|n| {
4973                n.clamp(
4974                    ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION,
4975                    ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION,
4976                )
4977            }),
4978            command_allowed_uids: file_config.command_allowed_uids.clone(),
4979            basic_auth_max_credential_bytes: file_config.basic_auth_max_credential_bytes,
4980            max_connections_per_ip: file_config
4981                .max_connections_per_ip
4982                .unwrap_or(DEFAULT_MAX_CONNECTIONS_PER_IP),
4983            max_connections_per_subnet: file_config
4984                .max_connections_per_subnet
4985                .unwrap_or(DEFAULT_MAX_CONNECTIONS_PER_SUBNET),
4986            subnet_ipv4_prefix: file_config
4987                .subnet_ipv4_prefix
4988                .unwrap_or(DEFAULT_SUBNET_IPV4_PREFIX),
4989            subnet_ipv6_prefix: file_config
4990                .subnet_ipv6_prefix
4991                .unwrap_or(DEFAULT_SUBNET_IPV6_PREFIX),
4992            retry_after: file_config.retry_after.unwrap_or(DEFAULT_RETRY_AFTER),
4993            max_connection_attempts: file_config
4994                .max_connection_attempts
4995                .unwrap_or(DEFAULT_MAX_CONNECTION_ATTEMPTS),
4996            splice_pipe_capacity_bytes: file_config.splice_pipe_capacity_bytes,
4997            ..Default::default()
4998        };
4999
5000        // POST: the buffer free-list floor is clamped to never exceed the
5001        // ceiling — the `std::cmp::min(min_buffers, max_buffers)` above is the
5002        // sole guarantor of this, so assert it held.
5003        debug_assert!(
5004            built.min_buffers <= built.max_buffers,
5005            "min_buffers must be clamped to <= max_buffers in the builder"
5006        );
5007        // POST: an explicit slab override, if present, was clamped into the
5008        // documented [MIN, MAX] window; an absent override stays None.
5009        debug_assert!(
5010            built.slab_entries_per_connection.is_none_or(|n| {
5011                (ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION
5012                    ..=ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION)
5013                    .contains(&n)
5014            }),
5015            "a set slab_entries_per_connection must be clamped into [MIN, MAX]"
5016        );
5017
5018        Self {
5019            file: file_config,
5020            known_addresses: HashMap::new(),
5021            expect_proxy_addresses: HashSet::new(),
5022            resolve_backends: true,
5023            built,
5024        }
5025    }
5026
5027    fn push_tls_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
5028        let listener = listener.to_tls(Some(&self.built))?;
5029        self.built.https_listeners.push(listener);
5030        Ok(())
5031    }
5032
5033    fn push_http_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
5034        let listener = listener.to_http(Some(&self.built))?;
5035        self.built.http_listeners.push(listener);
5036        Ok(())
5037    }
5038
5039    fn push_tcp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
5040        let listener = listener.to_tcp(Some(&self.built))?;
5041        self.built.tcp_listeners.push(listener);
5042        Ok(())
5043    }
5044
5045    fn push_udp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
5046        let listener = listener.to_udp(Some(&self.built))?;
5047        self.built.udp_listeners.push(listener);
5048        Ok(())
5049    }
5050
5051    fn populate_listeners(&mut self, listeners: Vec<ListenerBuilder>) -> Result<(), ConfigError> {
5052        let mut declared: HashSet<ListenerKey> = HashSet::new();
5053        for listener in listeners.iter() {
5054            // A listener is identified by (address, interface): two listeners
5055            // may share an address on different interfaces, never both.
5056            let key = ListenerKey::new(listener.address, listener.interface.as_deref());
5057            if !declared.insert(key.clone()) {
5058                if key.interface.is_none() {
5059                    return Err(ConfigError::ListenerAddressAlreadyInUse(listener.address));
5060                }
5061                return Err(ConfigError::ListenerAlreadyDeclared(key));
5062            }
5063
5064            let protocol = listener
5065                .protocol
5066                .ok_or(ConfigError::Missing(MissingKind::Protocol))?;
5067
5068            // Frontends name their listener by address alone, and resolve its
5069            // protocol from `known_addresses`: listeners sharing an address on
5070            // different interfaces must agree on it.
5071            if let Some(known) = self.known_addresses.get(&listener.address)
5072                && *known != protocol
5073            {
5074                return Err(ConfigError::ListenerProtocolMismatchOnAddress(
5075                    listener.address,
5076                ));
5077            }
5078
5079            self.known_addresses.insert(listener.address, protocol);
5080            if listener.expect_proxy == Some(true) {
5081                self.expect_proxy_addresses.insert(listener.address);
5082            }
5083
5084            if listener.public_address.is_some() && listener.expect_proxy == Some(true) {
5085                return Err(ConfigError::Incompatible {
5086                    object: ObjectKind::Listener,
5087                    id: listener.address.to_string(),
5088                    kind: IncompatibilityKind::PublicAddress,
5089                });
5090            }
5091
5092            match protocol {
5093                ListenerProtocol::Https => self.push_tls_listener(listener.clone())?,
5094                ListenerProtocol::Http => self.push_http_listener(listener.clone())?,
5095                ListenerProtocol::Tcp => self.push_tcp_listener(listener.clone())?,
5096                ListenerProtocol::Udp => self.push_udp_listener(listener.clone())?,
5097            }
5098        }
5099        Ok(())
5100    }
5101
5102    fn populate_clusters(
5103        &mut self,
5104        mut file_cluster_configs: HashMap<String, FileClusterConfig>,
5105    ) -> Result<(), ConfigError> {
5106        for (id, file_cluster_config) in file_cluster_configs.drain() {
5107            let resolver: Option<&BackendResolver<'_>> =
5108                self.resolve_backends.then_some(&system_resolver);
5109            let mut cluster_config = file_cluster_config.to_cluster_config_with(
5110                id.as_str(),
5111                &self.expect_proxy_addresses,
5112                resolver,
5113            )?;
5114
5115            match cluster_config {
5116                ClusterConfig::Http(ref mut http) => {
5117                    for frontend in http.frontends.iter_mut() {
5118                        match self.known_addresses.get(&frontend.address) {
5119                            Some(ListenerProtocol::Tcp) => {
5120                                return Err(ConfigError::WrongFrontendProtocol(
5121                                    ListenerProtocol::Tcp,
5122                                ));
5123                            }
5124                            Some(ListenerProtocol::Udp) => {
5125                                return Err(ConfigError::WrongFrontendProtocol(
5126                                    ListenerProtocol::Udp,
5127                                ));
5128                            }
5129                            Some(ListenerProtocol::Http) => {
5130                                if frontend.certificate.is_some() {
5131                                    return Err(ConfigError::WrongFrontendProtocol(
5132                                        ListenerProtocol::Http,
5133                                    ));
5134                                }
5135                            }
5136                            Some(ListenerProtocol::Https) => {
5137                                if frontend.certificate.is_none() {
5138                                    if let Some(https_listener) =
5139                                        self.built.https_listeners.iter().find(|listener| {
5140                                            listener.address == frontend.address.into()
5141                                                && listener.certificate.is_some()
5142                                        })
5143                                    {
5144                                        //println!("using listener certificate for {:}", frontend.address);
5145                                        frontend
5146                                            .certificate
5147                                            .clone_from(&https_listener.certificate);
5148                                        frontend.certificate_chain =
5149                                            Some(https_listener.certificate_chain.clone());
5150                                        frontend.key.clone_from(&https_listener.key);
5151                                    }
5152                                    if frontend.certificate.is_none() {
5153                                        debug!("known addresses: {:?}", self.known_addresses);
5154                                        debug!("frontend: {:?}", frontend);
5155                                        return Err(ConfigError::WrongFrontendProtocol(
5156                                            ListenerProtocol::Https,
5157                                        ));
5158                                    }
5159                                }
5160                            }
5161                            None => {
5162                                // create a default listener for that front
5163                                let file_listener_protocol = if frontend.certificate.is_some() {
5164                                    self.push_tls_listener(ListenerBuilder::new(
5165                                        frontend.address.into(),
5166                                        ListenerProtocol::Https,
5167                                    ))?;
5168
5169                                    ListenerProtocol::Https
5170                                } else {
5171                                    self.push_http_listener(ListenerBuilder::new(
5172                                        frontend.address.into(),
5173                                        ListenerProtocol::Http,
5174                                    ))?;
5175
5176                                    ListenerProtocol::Http
5177                                };
5178                                self.known_addresses
5179                                    .insert(frontend.address, file_listener_protocol);
5180                            }
5181                        }
5182                    }
5183                }
5184                ClusterConfig::Tcp(ref mut tcp) => {
5185                    //FIXME: verify that different TCP clusters do not request the same address
5186                    for frontend in tcp.frontends.iter_mut() {
5187                        match self.known_addresses.get(&frontend.address) {
5188                            Some(ListenerProtocol::Http) | Some(ListenerProtocol::Https) => {
5189                                return Err(ConfigError::WrongFrontendProtocol(
5190                                    ListenerProtocol::Http,
5191                                ));
5192                            }
5193                            Some(ListenerProtocol::Udp) => {
5194                                // A `protocol = "tcp"` cluster whose frontend
5195                                // points at a `protocol = "udp"` listener is a
5196                                // UDP cluster (datagram knobs under
5197                                // `[clusters.<id>.udp]`). Mark the frontend so
5198                                // `generate_requests` emits `AddUdpFrontend`.
5199                                frontend.udp = true;
5200                                // Datagrams are relayed over UDP sockets: a
5201                                // UNIX stream backend cannot carry them.
5202                                if let Some(backend) =
5203                                    tcp.backends.iter().find(|b| b.unix_path.is_some())
5204                                {
5205                                    return Err(ConfigError::InvalidBackendUnixPath {
5206                                        cluster_id: tcp.cluster_id.to_owned(),
5207                                        address: backend.address,
5208                                        reason: "a UDP cluster cannot use unix_path backends",
5209                                    });
5210                                }
5211                            }
5212                            Some(ListenerProtocol::Tcp) => {}
5213                            None => {
5214                                // create a default listener for that front
5215                                self.push_tcp_listener(ListenerBuilder::new(
5216                                    frontend.address.into(),
5217                                    ListenerProtocol::Tcp,
5218                                ))?;
5219                                self.known_addresses
5220                                    .insert(frontend.address, ListenerProtocol::Tcp);
5221                            }
5222                        }
5223                    }
5224                }
5225            }
5226
5227            self.built.clusters.insert(id, cluster_config);
5228        }
5229        Ok(())
5230    }
5231
5232    /// Builds the [`Config`] without resolving `host:port` backends, for a
5233    /// caller that only reads its scalar settings (the control commands).
5234    /// Everything else is validated as usual.
5235    pub fn without_backend_resolution(mut self) -> Self {
5236        self.resolve_backends = false;
5237        self
5238    }
5239
5240    /// Builds a [`Config`], populated with listeners and clusters
5241    pub fn into_config(&mut self) -> Result<Config, ConfigError> {
5242        // Subnet prefix lengths must fit their address family. Checked
5243        // here rather than clamped in `ConfigBuilder::new` (which cannot
5244        // fail) so a typo such as `subnet_ipv4_prefix = 64` stops the
5245        // load instead of quietly enforcing /32.
5246        if self.built.subnet_ipv4_prefix > MAX_SUBNET_IPV4_PREFIX {
5247            return Err(ConfigError::InvalidSubnetPrefix {
5248                key: "subnet_ipv4_prefix",
5249                family: "IPv4",
5250                value: self.built.subnet_ipv4_prefix,
5251                maximum: MAX_SUBNET_IPV4_PREFIX,
5252            });
5253        }
5254        if self.built.subnet_ipv6_prefix > MAX_SUBNET_IPV6_PREFIX {
5255            return Err(ConfigError::InvalidSubnetPrefix {
5256                key: "subnet_ipv6_prefix",
5257                family: "IPv6",
5258                value: self.built.subnet_ipv6_prefix,
5259                maximum: MAX_SUBNET_IPV6_PREFIX,
5260            });
5261        }
5262        validate_max_connection_attempts(self.built.max_connection_attempts).map_err(|reason| {
5263            ConfigError::InvalidMaxConnectionAttempts {
5264                cluster_id: None,
5265                value: self.built.max_connection_attempts,
5266                reason,
5267            }
5268        })?;
5269
5270        if let Some(listeners) = &self.file.listeners {
5271            self.populate_listeners(listeners.clone())?;
5272        }
5273
5274        if let Some(file_cluster_configs) = &self.file.clusters {
5275            self.populate_clusters(file_cluster_configs.clone())?;
5276        }
5277
5278        // TCP SNI/ALPN routing invariants (sozu-proxy/sozu#1279). Collect
5279        // every TCP frontend's (address, sni, alpn) across all clusters,
5280        // then validate:
5281        //   (c) a listener must not mix a no-SNI frontend with any
5282        //       SNI-scoped frontend — an SNI-enabled listener prereads the
5283        //       ClientHello, so a raw-TCP fallback on the same address is
5284        //       unreachable for clients that don't send SNI and ambiguous
5285        //       for those that do;
5286        //   (b) two frontends on the same (address, sni) must not share an
5287        //       ALPN protocol, and at most one may leave alpn empty (the
5288        //       catch-all) — otherwise routing on that listener would
5289        //       depend on iteration order rather than configuration;
5290        //   (d)/(e) sni_preread_timeout must not exceed front_timeout, and
5291        //       sni_preread_max_bytes must not exceed buffer_size, on any
5292        //       listener an SNI frontend targets. Both are gated on
5293        //       SNI-presence (not just any TCP listener) so a legacy
5294        //       TCP-only config with a low front_timeout or small
5295        //       buffer_size — set long before this feature existed and
5296        //       never opting into SNI — keeps loading byte-identically.
5297        type SniAlpnByAddress = HashMap<SocketAddr, Vec<(Option<String>, Vec<String>)>>;
5298        let mut frontends_by_address: SniAlpnByAddress = HashMap::new();
5299        let mut addresses_with_no_sni_frontend: HashSet<SocketAddr> = HashSet::new();
5300        for cluster in self.built.clusters.values() {
5301            if let ClusterConfig::Tcp(tcp) = cluster {
5302                for frontend in &tcp.frontends {
5303                    // A `protocol = "tcp"` cluster frontend resolved against a
5304                    // `protocol = "udp"` listener (`frontend.udp`, set in
5305                    // `populate_clusters`) emits `AddUdpFrontend`, which
5306                    // carries no `sni`/`alpn` on the wire — it is not a real
5307                    // SNI-preread TCP frontend and must not participate in
5308                    // these TCP-only invariants.
5309                    if frontend.udp {
5310                        continue;
5311                    }
5312                    if frontend.sni.is_none() {
5313                        addresses_with_no_sni_frontend.insert(frontend.address);
5314                    }
5315                    frontends_by_address
5316                        .entry(frontend.address)
5317                        .or_default()
5318                        .push((frontend.sni.clone(), frontend.alpn.clone()));
5319                }
5320            }
5321        }
5322
5323        let mut addresses_with_sni_frontend: HashSet<SocketAddr> = HashSet::new();
5324        for (address, frontends) in &frontends_by_address {
5325            if !frontends.iter().any(|(sni, _)| sni.is_some()) {
5326                continue;
5327            }
5328            addresses_with_sni_frontend.insert(*address);
5329
5330            if addresses_with_no_sni_frontend.contains(address) {
5331                return Err(ConfigError::TcpListenerMixesSniAndNoSni { address: *address });
5332            }
5333
5334            let mut alpn_lists_by_sni: HashMap<Option<String>, Vec<&Vec<String>>> = HashMap::new();
5335            for (sni, alpn) in frontends {
5336                alpn_lists_by_sni.entry(sni.clone()).or_default().push(alpn);
5337            }
5338            for (sni, alpn_lists) in alpn_lists_by_sni {
5339                let mut seen_protocols: HashSet<&str> = HashSet::new();
5340                let mut catch_all_count = 0usize;
5341                for alpn in alpn_lists {
5342                    if alpn.is_empty() {
5343                        catch_all_count += 1;
5344                        if catch_all_count > 1 {
5345                            return Err(ConfigError::TcpFrontendMultipleAlpnCatchAll {
5346                                address: *address,
5347                                sni: sni.clone(),
5348                            });
5349                        }
5350                        continue;
5351                    }
5352                    for protocol in alpn {
5353                        if !seen_protocols.insert(protocol.as_str()) {
5354                            return Err(ConfigError::TcpFrontendAlpnOverlap {
5355                                address: *address,
5356                                sni: sni.clone(),
5357                                protocol: protocol.clone(),
5358                            });
5359                        }
5360                    }
5361                }
5362            }
5363        }
5364
5365        for listener in &self.built.tcp_listeners {
5366            let address: SocketAddr = listener.address.into();
5367            if !addresses_with_sni_frontend.contains(&address) {
5368                continue;
5369            }
5370            let sni_preread_timeout = listener
5371                .sni_preread_timeout
5372                .unwrap_or(DEFAULT_SNI_PREREAD_TIMEOUT);
5373            if sni_preread_timeout > listener.front_timeout {
5374                return Err(ConfigError::SniPrereadTimeoutExceedsFrontTimeout {
5375                    address,
5376                    sni_preread_timeout,
5377                    front_timeout: listener.front_timeout,
5378                });
5379            }
5380            let sni_preread_max_bytes = listener
5381                .sni_preread_max_bytes
5382                .unwrap_or(DEFAULT_SNI_PREREAD_MAX_BYTES);
5383            if sni_preread_max_bytes < MIN_SNI_PREREAD_MAX_BYTES {
5384                return Err(ConfigError::SniPrereadMaxBytesTooSmall {
5385                    address,
5386                    sni_preread_max_bytes,
5387                    minimum: MIN_SNI_PREREAD_MAX_BYTES,
5388                });
5389            }
5390            if u64::from(sni_preread_max_bytes) > self.built.buffer_size {
5391                return Err(ConfigError::SniPrereadMaxBytesExceedsBufferSize {
5392                    address,
5393                    sni_preread_max_bytes,
5394                    buffer_size: self.built.buffer_size,
5395                });
5396            }
5397        }
5398
5399        // RFC 9113 §6.5.2 + §4.1: the H2 mux must accept up to
5400        // SETTINGS_MAX_FRAME_SIZE (16 384) + 9-byte frame header in a single
5401        // kawa buffer. If any HTTPS listener advertises "h2" in its ALPN list
5402        // and the global buffer_size is below H2_MIN_BUFFER_SIZE, the mux
5403        // deadlocks on full-size DATA / HEADERS / CONTINUATION frames until
5404        // the session timeout fires. An HTTP listener with
5405        // `h2c_prior_knowledge` serves the same H2 mux over cleartext
5406        // (sozu-proxy/sozu#1911), so it counts too. Reject at config load so
5407        // the failure mode surfaces at boot, not under traffic.
5408        // Long-form rationale: `lib/src/protocol/mux/LIFECYCLE.md`.
5409        let h2_listeners = self
5410            .built
5411            .https_listeners
5412            .iter()
5413            .filter(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
5414            .count()
5415            + self
5416                .built
5417                .http_listeners
5418                .iter()
5419                .filter(|l| l.h2c_prior_knowledge == Some(true))
5420                .count();
5421        if h2_listeners > 0 && self.built.buffer_size < H2_MIN_BUFFER_SIZE {
5422            return Err(ConfigError::BufferSizeTooSmallForH2 {
5423                buffer_size: self.built.buffer_size,
5424                minimum: H2_MIN_BUFFER_SIZE,
5425                listeners: h2_listeners,
5426            });
5427        }
5428
5429        // The command channel (`Channel::new`, `command/src/channel.rs`) allocates its
5430        // initial buffer at `command_buffer_size` and must never let it exceed
5431        // `max_command_buffer_size` -- every growth/shrink path on that channel reasons
5432        // against that ceiling. Reject at config load, where the error can name both
5433        // keys and both values, rather than let a misconfigured pair reach the real
5434        // master<->worker channel (sozu-proxy/sozu#1416).
5435        if self.built.command_buffer_size > self.built.max_command_buffer_size {
5436            return Err(ConfigError::CommandBufferSizeExceedsMax {
5437                command_buffer_size: self.built.command_buffer_size,
5438                max_command_buffer_size: self.built.max_command_buffer_size,
5439            });
5440        }
5441
5442        // Warn (no hard reject) when the configured Basic-auth credential
5443        // cap is large enough to dominate the per-frontend buffer. The
5444        // worker copies a decoded credential into a transient allocation
5445        // sized by this cap; values >= 33% of `buffer_size` mean a single
5446        // failed-auth attempt can hold a third of the buffer's worth of
5447        // bytes, which combined with in-flight request/response framing
5448        // pushes the buffer toward back-pressure under load. Log only —
5449        // operators with deliberate threat models may choose this
5450        // trade-off, but the surprise needs to be visible.
5451        if let Some(cap) = self.built.basic_auth_max_credential_bytes {
5452            let third = self.built.buffer_size / 3;
5453            if cap >= third {
5454                warn!(
5455                    "basic_auth_max_credential_bytes = {} is >= buffer_size / 3 ({}); \
5456                     a hostile peer can pin ~33% of the per-frontend buffer per failed auth \
5457                     attempt. Consider lowering basic_auth_max_credential_bytes (typical \
5458                     credentials are <100 bytes) or raising buffer_size.",
5459                    cap, third
5460                );
5461            }
5462        }
5463
5464        // The eviction batch is `(max_connections / 100).max(1)` — a 1% ratio
5465        // by design. Below 100 connections the floor of 1 means each cap
5466        // event evicts a larger share than 1% of capacity (e.g. 4% at
5467        // max_connections=25), which can surprise an operator who reads the
5468        // knob as "1% per round". Warn at config load so the discrepancy is
5469        // visible at boot, not under traffic.
5470        if self.built.evict_on_queue_full && self.built.max_connections < 100 {
5471            let pct = 100usize.div_ceil(self.built.max_connections);
5472            warn!(
5473                "evict_on_queue_full enabled with max_connections = {}; the eviction batch \
5474                 clamps to 1, equivalent to ~{}% of capacity per cap event (the knob is \
5475                 documented as 1%). Confirm this is intended.",
5476                self.built.max_connections, pct
5477            );
5478        }
5479
5480        let command_socket_path = self.file.command_socket.clone().unwrap_or({
5481            let mut path = env::current_dir().map_err(|e| ConfigError::Env(e.to_string()))?;
5482            path.push("sozu.sock");
5483            let verified_path = path
5484                .to_str()
5485                .ok_or(ConfigError::InvalidPath(path.clone()))?;
5486            verified_path.to_owned()
5487        });
5488
5489        if let (None, Some(true)) = (&self.file.saved_state, &self.file.automatic_state_save) {
5490            return Err(ConfigError::Missing(MissingKind::SavedState));
5491        }
5492
5493        let command_socket_mode = match self.file.command_socket_mode.as_deref() {
5494            Some(mode) => parse_command_socket_mode(mode)?,
5495            None => DEFAULT_COMMAND_SOCKET_MODE,
5496        };
5497
5498        let config = Config {
5499            command_socket: command_socket_path,
5500            command_socket_mode,
5501            ..self.built.clone()
5502        };
5503
5504        // POST: a successfully built config satisfies the buffer-pool
5505        // invariants every worker relies on.
5506        // 1. min_buffers <= max_buffers — guaranteed by the `std::cmp::min`
5507        //    clamp in `new`; a violation would let a worker size its free-list
5508        //    floor above its ceiling.
5509        debug_assert!(
5510            config.min_buffers <= config.max_buffers,
5511            "min_buffers must not exceed max_buffers"
5512        );
5513        // 2. If any HTTPS listener advertises h2 in its ALPN, or any HTTP
5514        //    listener serves h2c, the global
5515        //    buffer_size is at least the H2 minimum — otherwise the early
5516        //    return above would have produced a BufferSizeTooSmallForH2 error
5517        //    rather than this Ok. (Recomputed here so the assert is independent
5518        //    of the local `h2_listeners` binding above.)
5519        debug_assert!(
5520            !config
5521                .https_listeners
5522                .iter()
5523                .any(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
5524                && !config
5525                    .http_listeners
5526                    .iter()
5527                    .any(|l| l.h2c_prior_knowledge == Some(true))
5528                || config.buffer_size >= H2_MIN_BUFFER_SIZE,
5529            "an h2-advertising config must satisfy the H2 minimum buffer size"
5530        );
5531        Ok(config)
5532    }
5533}
5534
5535/// Sōzu configuration, populated with clusters and listeners.
5536///
5537/// This struct is used on startup to generate `WorkerRequest`s
5538#[derive(Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
5539pub struct Config {
5540    pub config_path: String,
5541    pub command_socket: String,
5542    /// Permission bits applied to the command socket after `bind(2)`, at most
5543    /// [`MAX_COMMAND_SOCKET_MODE`]. An upgrade snapshot written by a main
5544    /// process that predates the key deserializes to
5545    /// [`DEFAULT_COMMAND_SOCKET_MODE`].
5546    #[serde(default = "default_command_socket_mode")]
5547    pub command_socket_mode: u32,
5548    pub command_buffer_size: u64,
5549    pub max_command_buffer_size: u64,
5550    pub max_connections: usize,
5551    pub min_buffers: u64,
5552    pub max_buffers: u64,
5553    pub buffer_size: u64,
5554    pub saved_state: Option<String>,
5555    #[serde(default)]
5556    pub automatic_state_save: bool,
5557    pub log_level: String,
5558    pub log_target: String,
5559    pub log_colored: bool,
5560    /// Optional dedicated file path for the control-plane audit log. See
5561    /// `FileConfig::audit_logs_target` for rationale.
5562    #[serde(default)]
5563    pub audit_logs_target: Option<String>,
5564    /// Optional JSON mirror of the audit log; see
5565    /// `FileConfig::audit_logs_json_target`.
5566    #[serde(default)]
5567    pub audit_logs_json_target: Option<String>,
5568    #[serde(default)]
5569    pub access_logs_target: Option<String>,
5570    pub access_logs_format: Option<AccessLogFormat>,
5571    pub access_logs_colored: Option<bool>,
5572    pub worker_count: u16,
5573    pub worker_automatic_restart: bool,
5574    pub metrics: Option<MetricsConfig>,
5575    #[serde(default = "default_disable_cluster_metrics")]
5576    pub disable_cluster_metrics: bool,
5577    pub http_listeners: Vec<HttpListenerConfig>,
5578    pub https_listeners: Vec<HttpsListenerConfig>,
5579    pub tcp_listeners: Vec<TcpListenerConfig>,
5580    #[serde(default)]
5581    pub udp_listeners: Vec<UdpListenerConfig>,
5582    pub clusters: HashMap<String, ClusterConfig>,
5583    pub handle_process_affinity: bool,
5584    pub ctl_command_timeout: u64,
5585    pub pid_file_path: Option<String>,
5586    pub activate_listeners: bool,
5587    #[serde(default = "default_front_timeout")]
5588    pub front_timeout: u32,
5589    #[serde(default = "default_back_timeout")]
5590    pub back_timeout: u32,
5591    #[serde(default = "default_connect_timeout")]
5592    pub connect_timeout: u32,
5593    #[serde(default = "default_zombie_check_interval")]
5594    pub zombie_check_interval: u32,
5595    #[serde(default = "default_accept_queue_timeout")]
5596    pub accept_queue_timeout: u32,
5597    #[serde(default = "default_evict_on_queue_full")]
5598    pub evict_on_queue_full: bool,
5599    #[serde(default = "default_request_timeout")]
5600    pub request_timeout: u32,
5601    #[serde(default = "default_worker_timeout")]
5602    pub worker_timeout: u32,
5603    /// Slab-entries-per-connection multiplier exposed for operators with
5604    /// fan-out topologies that exceed the default 4 backends per session.
5605    /// `None` means the default (4) applies; set values are clamped to
5606    /// [`ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION`,
5607    /// `ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION`] = [2, 32]. Slab
5608    /// capacity is `10 + slab_entries_per_connection * max_connections`.
5609    #[serde(default)]
5610    pub slab_entries_per_connection: Option<u64>,
5611    /// Optional allowlist of UIDs permitted to invoke command-socket
5612    /// requests. `None` keeps the historical "any same-UID local process"
5613    /// behaviour. When `Some`, every request whose `SO_PEERCRED` UID is
5614    /// not in the list is rejected before reaching dispatch.
5615    #[serde(default)]
5616    pub command_allowed_uids: Option<Vec<u32>>,
5617    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
5618    /// payload accepted by `mux::auth`. `None` keeps the compile-time
5619    /// default of 4096. Set once on each worker at boot via
5620    /// [`ServerConfig::basic_auth_max_credential_bytes`].
5621    #[serde(default)]
5622    pub basic_auth_max_credential_bytes: Option<u64>,
5623    /// Default per-(cluster, source-IP) connection limit. `0` means
5624    /// unlimited. Each cluster may override via its own
5625    /// `max_connections_per_ip`. Source IP attribution honours the
5626    /// proxy-protocol header when present. UDP listeners ignore it: only a
5627    /// cluster's own value limits UDP flows.
5628    #[serde(default = "default_max_connections_per_ip")]
5629    pub max_connections_per_ip: u64,
5630    /// Default per-(cluster, source-SUBNET) connection limit. `0` means
5631    /// unlimited and is the default, so the subnet limiter is inert
5632    /// until an operator turns it on. An independent second counter
5633    /// beside `max_connections_per_ip`, not a replacement: both gates
5634    /// must admit. Each cluster may override.
5635    #[serde(default = "default_max_connections_per_subnet")]
5636    pub max_connections_per_subnet: u64,
5637    /// IPv4 prefix length (bits, 0-32) defining the subnet key. 32
5638    /// (default) masks nothing.
5639    #[serde(default = "default_subnet_ipv4_prefix")]
5640    pub subnet_ipv4_prefix: u32,
5641    /// IPv6 prefix length (bits, 0-128) defining the subnet key. 128
5642    /// (default) masks nothing.
5643    #[serde(default = "default_subnet_ipv6_prefix")]
5644    pub subnet_ipv6_prefix: u32,
5645    /// Default `Retry-After` header value (seconds) emitted on HTTP 429
5646    /// responses. `0` omits the header.
5647    #[serde(default = "default_retry_after")]
5648    pub retry_after: u32,
5649    /// Backend connection attempt budget of every request, the first attempt
5650    /// included. Each cluster may override it.
5651    #[serde(default = "default_max_connection_attempts")]
5652    pub max_connection_attempts: u32,
5653    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
5654    /// zero-copy direction. `None` keeps the kernel default of 64 KiB.
5655    /// Applied via `fcntl(F_SETPIPE_SZ)` per pipe at `SplicePipe::new`;
5656    /// the kernel rounds up to a page boundary and clamps at
5657    /// `/proc/sys/fs/pipe-max-size`. Linux-only; ignored on builds
5658    /// without the `splice` feature.
5659    #[serde(default)]
5660    pub splice_pipe_capacity_bytes: Option<u64>,
5661}
5662
5663fn default_command_socket_mode() -> u32 {
5664    DEFAULT_COMMAND_SOCKET_MODE
5665}
5666
5667/// Parse `command_socket_mode`: one to four octal digits, a leading `0`
5668/// allowed (`"0660"` and `"660"` are the same mode), no `0o` prefix and no
5669/// sign. The value must not exceed [`MAX_COMMAND_SOCKET_MODE`], which refuses
5670/// setuid, setgid and the sticky bit, and must keep the owner's read and
5671/// write bits: without them the proxy's own user could no longer connect to
5672/// run `status`, `reload` or `upgrade`.
5673pub fn parse_command_socket_mode(value: &str) -> Result<u32, ConfigError> {
5674    let invalid = |reason| ConfigError::InvalidCommandSocketMode {
5675        value: value.to_owned(),
5676        reason,
5677    };
5678    if value.is_empty() || value.len() > 4 || !value.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
5679        return Err(invalid(
5680            "expected one to four octal digits, such as \"0660\" or \"660\"",
5681        ));
5682    }
5683    let mode =
5684        u32::from_str_radix(value, 8).map_err(|_| invalid("expected one to four octal digits"))?;
5685    if mode > MAX_COMMAND_SOCKET_MODE {
5686        return Err(invalid(
5687            "only permission bits (at most 0777) are allowed; setuid, setgid and sticky are refused",
5688        ));
5689    }
5690    if mode & 0o600 != 0o600 {
5691        return Err(invalid("the owner must keep read and write (0600)"));
5692    }
5693    Ok(mode)
5694}
5695
5696fn default_front_timeout() -> u32 {
5697    DEFAULT_FRONT_TIMEOUT
5698}
5699
5700fn default_back_timeout() -> u32 {
5701    DEFAULT_BACK_TIMEOUT
5702}
5703
5704fn default_connect_timeout() -> u32 {
5705    DEFAULT_CONNECT_TIMEOUT
5706}
5707
5708fn default_request_timeout() -> u32 {
5709    DEFAULT_REQUEST_TIMEOUT
5710}
5711
5712fn default_zombie_check_interval() -> u32 {
5713    DEFAULT_ZOMBIE_CHECK_INTERVAL
5714}
5715
5716fn default_accept_queue_timeout() -> u32 {
5717    DEFAULT_ACCEPT_QUEUE_TIMEOUT
5718}
5719
5720fn default_evict_on_queue_full() -> bool {
5721    DEFAULT_EVICT_ON_QUEUE_FULL
5722}
5723
5724fn default_disable_cluster_metrics() -> bool {
5725    DEFAULT_DISABLE_CLUSTER_METRICS
5726}
5727
5728fn default_worker_timeout() -> u32 {
5729    DEFAULT_WORKER_TIMEOUT
5730}
5731
5732fn default_max_connections_per_ip() -> u64 {
5733    DEFAULT_MAX_CONNECTIONS_PER_IP
5734}
5735
5736fn default_max_connections_per_subnet() -> u64 {
5737    DEFAULT_MAX_CONNECTIONS_PER_SUBNET
5738}
5739
5740fn default_subnet_ipv4_prefix() -> u32 {
5741    DEFAULT_SUBNET_IPV4_PREFIX
5742}
5743
5744fn default_subnet_ipv6_prefix() -> u32 {
5745    DEFAULT_SUBNET_IPV6_PREFIX
5746}
5747
5748fn default_retry_after() -> u32 {
5749    DEFAULT_RETRY_AFTER
5750}
5751
5752fn default_max_connection_attempts() -> u32 {
5753    DEFAULT_MAX_CONNECTION_ATTEMPTS
5754}
5755
5756impl Config {
5757    /// Parse a TOML file and build a config out of it
5758    pub fn load_from_path(path: &str) -> Result<Config, ConfigError> {
5759        let file_config = FileConfig::load_from_path(path)?;
5760
5761        let mut config = ConfigBuilder::new(file_config, path).into_config()?;
5762
5763        // replace saved_state with a verified path
5764        config.saved_state = config.saved_state_path()?;
5765
5766        Ok(config)
5767    }
5768
5769    /// Like [`Config::load_from_path`], but `host:port` backends are not
5770    /// resolved: for the control commands, which read the scalar settings and
5771    /// must not depend on DNS. The rest of the file is validated as usual.
5772    pub fn load_from_path_for_control(path: &str) -> Result<Config, ConfigError> {
5773        let file_config = FileConfig::load_from_path(path)?;
5774
5775        let mut config = ConfigBuilder::new(file_config, path)
5776            .without_backend_resolution()
5777            .into_config()?;
5778        config.saved_state = config.saved_state_path()?;
5779
5780        Ok(config)
5781    }
5782
5783    /// yields requests intended to recreate a proxy that match the config
5784    pub fn generate_config_messages(&self) -> Result<Vec<WorkerRequest>, ConfigError> {
5785        let mut v = Vec::new();
5786        let mut count = 0u8;
5787
5788        for listener in &self.http_listeners {
5789            v.push(WorkerRequest {
5790                id: format!("CONFIG-{count}"),
5791                content: RequestType::AddHttpListener(listener.clone()).into(),
5792            });
5793            count += 1;
5794        }
5795
5796        for listener in &self.https_listeners {
5797            v.push(WorkerRequest {
5798                id: format!("CONFIG-{count}"),
5799                content: RequestType::add_https_listener(listener.clone()).into(),
5800            });
5801            count += 1;
5802        }
5803
5804        for listener in &self.tcp_listeners {
5805            v.push(WorkerRequest {
5806                id: format!("CONFIG-{count}"),
5807                content: RequestType::AddTcpListener(listener.clone()).into(),
5808            });
5809            count += 1;
5810        }
5811
5812        for listener in &self.udp_listeners {
5813            v.push(WorkerRequest {
5814                id: format!("CONFIG-{count}"),
5815                content: RequestType::AddUdpListener(listener.clone()).into(),
5816            });
5817            count += 1;
5818        }
5819
5820        for cluster in self.clusters.values() {
5821            let mut orders = cluster.generate_requests()?;
5822            for content in orders.drain(..) {
5823                v.push(WorkerRequest {
5824                    id: format!("CONFIG-{count}"),
5825                    content,
5826                });
5827                count += 1;
5828            }
5829        }
5830
5831        if self.activate_listeners {
5832            for listener in &self.http_listeners {
5833                v.push(WorkerRequest {
5834                    id: format!("CONFIG-{count}"),
5835                    content: RequestType::ActivateListener(ActivateListener {
5836                        interface: listener.interface.clone(),
5837                        address: listener.address,
5838                        proxy: ListenerType::Http.into(),
5839                        from_scm: false,
5840                    })
5841                    .into(),
5842                });
5843                count += 1;
5844            }
5845
5846            for listener in &self.https_listeners {
5847                v.push(WorkerRequest {
5848                    id: format!("CONFIG-{count}"),
5849                    content: RequestType::ActivateListener(ActivateListener {
5850                        interface: listener.interface.clone(),
5851                        address: listener.address,
5852                        proxy: ListenerType::Https.into(),
5853                        from_scm: false,
5854                    })
5855                    .into(),
5856                });
5857                count += 1;
5858            }
5859
5860            for listener in &self.tcp_listeners {
5861                v.push(WorkerRequest {
5862                    id: format!("CONFIG-{count}"),
5863                    content: RequestType::ActivateListener(ActivateListener {
5864                        interface: listener.interface.clone(),
5865                        address: listener.address,
5866                        proxy: ListenerType::Tcp.into(),
5867                        from_scm: false,
5868                    })
5869                    .into(),
5870                });
5871                count += 1;
5872            }
5873
5874            for listener in &self.udp_listeners {
5875                v.push(WorkerRequest {
5876                    id: format!("CONFIG-{count}"),
5877                    content: RequestType::ActivateListener(ActivateListener {
5878                        interface: listener.interface.clone(),
5879                        address: listener.address,
5880                        proxy: ListenerType::Udp.into(),
5881                        from_scm: false,
5882                    })
5883                    .into(),
5884                });
5885                count += 1;
5886            }
5887        }
5888
5889        if self.disable_cluster_metrics {
5890            v.push(WorkerRequest {
5891                id: format!("CONFIG-{count}"),
5892                content: RequestType::ConfigureMetrics(MetricsConfiguration::Disabled.into())
5893                    .into(),
5894            });
5895            // count += 1; // uncomment if code is added below
5896        }
5897
5898        Ok(v)
5899    }
5900
5901    /// Get the path of the UNIX socket used to communicate with Sōzu
5902    pub fn command_socket_path(&self) -> Result<String, ConfigError> {
5903        let config_path_buf = PathBuf::from(self.config_path.clone());
5904        let mut config_dir = config_path_buf
5905            .parent()
5906            .ok_or(ConfigError::NoFileParent(
5907                config_path_buf.to_string_lossy().to_string(),
5908            ))?
5909            .to_path_buf();
5910
5911        let socket_path = PathBuf::from(self.command_socket.clone());
5912
5913        let mut socket_parent_dir = match socket_path.parent() {
5914            // if the socket path is of the form "./sozu.sock",
5915            // then the parent is the directory where config.toml is situated
5916            None => config_dir,
5917            Some(path) => {
5918                // concatenate the config directory and the relative path of the socket
5919                config_dir.push(path);
5920                // canonicalize to remove double dots like /path/to/config/directory/../../path/to/socket/directory/
5921                config_dir.canonicalize().map_err(|io_error| {
5922                    ConfigError::SocketPathError(format!(
5923                        "Could not canonicalize path {config_dir:?}: {io_error}"
5924                    ))
5925                })?
5926            }
5927        };
5928
5929        let socket_name = socket_path
5930            .file_name()
5931            .ok_or(ConfigError::SocketPathError(format!(
5932                "could not get command socket file name from {socket_path:?}"
5933            )))?;
5934
5935        // concatenate parent directory and socket file name
5936        socket_parent_dir.push(socket_name);
5937
5938        let command_socket_path = socket_parent_dir
5939            .to_str()
5940            .ok_or(ConfigError::SocketPathError(format!(
5941                "Invalid socket path {socket_parent_dir:?}"
5942            )))?
5943            .to_string();
5944
5945        Ok(command_socket_path)
5946    }
5947
5948    /// Get the path of where the state will be saved
5949    fn saved_state_path(&self) -> Result<Option<String>, ConfigError> {
5950        let path = match self.saved_state.as_ref() {
5951            Some(path) => path,
5952            None => return Ok(None),
5953        };
5954
5955        debug!("saved_stated path in the config: {}", path);
5956        let config_path = PathBuf::from(self.config_path.clone());
5957
5958        debug!("Config path buffer: {:?}", config_path);
5959        let config_dir = config_path
5960            .parent()
5961            .ok_or(ConfigError::SaveStatePath(format!(
5962                "Could get parent directory of config file {config_path:?}"
5963            )))?;
5964
5965        debug!("Config folder: {:?}", config_dir);
5966        if !config_dir.exists() {
5967            create_dir_all(config_dir).map_err(|io_error| {
5968                ConfigError::SaveStatePath(format!(
5969                    "failed to create state parent directory '{config_dir:?}': {io_error}"
5970                ))
5971            })?;
5972        }
5973
5974        let mut saved_state_path_raw = config_dir.to_path_buf();
5975        saved_state_path_raw.push(path);
5976        debug!(
5977            "Looking for saved state on the path {:?}",
5978            saved_state_path_raw
5979        );
5980
5981        match metadata(path) {
5982            Err(err) if matches!(err.kind(), ErrorKind::NotFound) => {
5983                info!("Create an empty state file at '{}'", path);
5984                File::create(path).map_err(|io_error| {
5985                    ConfigError::SaveStatePath(format!(
5986                        "failed to create state file '{path:?}': {io_error}"
5987                    ))
5988                })?;
5989            }
5990            _ => {}
5991        }
5992
5993        saved_state_path_raw.canonicalize().map_err(|io_error| {
5994            ConfigError::SaveStatePath(format!(
5995                "could not get saved state path from config file input {path:?}: {io_error}"
5996            ))
5997        })?;
5998
5999        let stringified_path = saved_state_path_raw
6000            .to_str()
6001            .ok_or(ConfigError::SaveStatePath(format!(
6002                "Invalid path {saved_state_path_raw:?}"
6003            )))?
6004            .to_string();
6005
6006        Ok(Some(stringified_path))
6007    }
6008
6009    /// read any file to a string
6010    pub fn load_file(path: &str) -> Result<String, ConfigError> {
6011        std::fs::read_to_string(path).map_err(|io_error| ConfigError::FileRead {
6012            path_to_read: path.to_owned(),
6013            io_error,
6014        })
6015    }
6016
6017    /// read any file to bytes
6018    pub fn load_file_bytes(path: &str) -> Result<Vec<u8>, ConfigError> {
6019        std::fs::read(path).map_err(|io_error| ConfigError::FileRead {
6020            path_to_read: path.to_owned(),
6021            io_error,
6022        })
6023    }
6024}
6025
6026impl fmt::Debug for Config {
6027    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6028        f.debug_struct("Config")
6029            .field("config_path", &self.config_path)
6030            .field("command_socket", &self.command_socket)
6031            .field(
6032                "command_socket_mode",
6033                &format_args!("{:o}", self.command_socket_mode),
6034            )
6035            .field("command_buffer_size", &self.command_buffer_size)
6036            .field("max_command_buffer_size", &self.max_command_buffer_size)
6037            .field("max_connections", &self.max_connections)
6038            .field("min_buffers", &self.min_buffers)
6039            .field("max_buffers", &self.max_buffers)
6040            .field("buffer_size", &self.buffer_size)
6041            .field("saved_state", &self.saved_state)
6042            .field("automatic_state_save", &self.automatic_state_save)
6043            .field("log_level", &self.log_level)
6044            .field("log_target", &self.log_target)
6045            .field("access_logs_target", &self.access_logs_target)
6046            .field("audit_logs_target", &self.audit_logs_target)
6047            .field("audit_logs_json_target", &self.audit_logs_json_target)
6048            .field("access_logs_format", &self.access_logs_format)
6049            .field("worker_count", &self.worker_count)
6050            .field("worker_automatic_restart", &self.worker_automatic_restart)
6051            .field("metrics", &self.metrics)
6052            .field("disable_cluster_metrics", &self.disable_cluster_metrics)
6053            .field("handle_process_affinity", &self.handle_process_affinity)
6054            .field("ctl_command_timeout", &self.ctl_command_timeout)
6055            .field("pid_file_path", &self.pid_file_path)
6056            .field("activate_listeners", &self.activate_listeners)
6057            .field("front_timeout", &self.front_timeout)
6058            .field("back_timeout", &self.back_timeout)
6059            .field("connect_timeout", &self.connect_timeout)
6060            .field("zombie_check_interval", &self.zombie_check_interval)
6061            .field("accept_queue_timeout", &self.accept_queue_timeout)
6062            .field("evict_on_queue_full", &self.evict_on_queue_full)
6063            .field("request_timeout", &self.request_timeout)
6064            .field("worker_timeout", &self.worker_timeout)
6065            .finish()
6066    }
6067}
6068
6069/// The diagnostic of a configuration file that does not parse: its path and,
6070/// when known, the byte range of the offence.
6071///
6072/// Neither the source nor `toml::de::Error`'s `Display` is used: the latter
6073/// quotes the offending line, and a serde type error quotes the offending
6074/// value, which may be a health-check header credential.
6075fn toml_error_diagnostic(path: &str, error: &toml::de::Error) -> String {
6076    match error.span() {
6077        Some(Range { start, end }) => format!(
6078            "error parsing the configuration file '{path}' at bytes {start}..{end}: invalid TOML syntax or unexpected value (the offending text is not shown)"
6079        ),
6080        None => format!(
6081            "error parsing the configuration file '{path}': invalid TOML syntax or unexpected value (the offending text is not shown)"
6082        ),
6083    }
6084}
6085
6086impl ServerConfig {
6087    /// Default number of slab entries per connection. Set to 4 to accommodate
6088    /// H2 multiplexing (1 frontend + up to 3 backend connections per
6089    /// frontend with stream multiplexing). Previous value was 2 for H1-only
6090    /// operation. Operators with topologies that fan out across more
6091    /// clusters per session can override via `slab_entries_per_connection`
6092    /// in the config (clamped to [2, 32]).
6093    pub const DEFAULT_SLAB_ENTRIES_PER_CONNECTION: u64 = 4;
6094    /// Lower bound for the runtime knob. Below 2 the slab cannot hold one
6095    /// frontend + one backend per session.
6096    pub const MIN_SLAB_ENTRIES_PER_CONNECTION: u64 = 2;
6097    /// Upper bound for the runtime knob. 32 caps memory blow-up from a
6098    /// runaway config; 32 backends per frontend covers any sane topology.
6099    pub const MAX_SLAB_ENTRIES_PER_CONNECTION: u64 = 32;
6100
6101    /// Effective slab-entries-per-connection. Applies the [MIN, MAX] clamp
6102    /// and falls back to the default when the proto field is absent or 0.
6103    pub fn effective_slab_entries_per_connection(&self) -> u64 {
6104        let effective = match self.slab_entries_per_connection {
6105            Some(0) | None => Self::DEFAULT_SLAB_ENTRIES_PER_CONNECTION,
6106            Some(n) => n.clamp(
6107                Self::MIN_SLAB_ENTRIES_PER_CONNECTION,
6108                Self::MAX_SLAB_ENTRIES_PER_CONNECTION,
6109            ),
6110        };
6111        // POST: the effective value is always inside the documented clamp
6112        // window [MIN, MAX], regardless of the raw config input. The default
6113        // itself sits inside that window, so every branch satisfies the bound.
6114        debug_assert!(
6115            (Self::MIN_SLAB_ENTRIES_PER_CONNECTION..=Self::MAX_SLAB_ENTRIES_PER_CONNECTION)
6116                .contains(&effective),
6117            "effective slab entries per connection must stay within [MIN, MAX]"
6118        );
6119        effective
6120    }
6121
6122    /// Size of the slab for the Session manager.
6123    ///
6124    /// With HTTP/2 multiplexing, each frontend session can have multiple backend
6125    /// connections (one per cluster), so we allocate
6126    /// [`Self::effective_slab_entries_per_connection`] entries per connection
6127    /// instead of the old H1-only multiplier of 2.
6128    pub fn slab_capacity(&self) -> u64 {
6129        let per_conn = self.effective_slab_entries_per_connection();
6130        let capacity = 10 + per_conn * self.max_connections;
6131        // POST: the slab always reserves the 10-entry base (listeners, command
6132        // channel, etc.) plus at least MIN entries per connection, so it can
6133        // never be smaller than the base. Strict `>` for any non-zero
6134        // max_connections since per_conn >= MIN >= 2.
6135        debug_assert!(
6136            capacity >= 10,
6137            "slab capacity must reserve the base entries"
6138        );
6139        debug_assert!(
6140            self.max_connections == 0 || capacity > 10,
6141            "a non-zero connection cap must reserve per-connection slab entries"
6142        );
6143        capacity
6144    }
6145}
6146
6147/// reduce the config to the bare minimum needed by a worker
6148impl From<&Config> for ServerConfig {
6149    fn from(config: &Config) -> Self {
6150        let metrics = config.metrics.clone().map(|m| ServerMetricsConfig {
6151            address: m.address.to_string(),
6152            tagged_metrics: m.tagged_metrics,
6153            prefix: m.prefix,
6154            detail: Some(MetricDetail::from(m.detail) as i32),
6155        });
6156        let server_config = Self {
6157            max_connections: config.max_connections as u64,
6158            front_timeout: config.front_timeout,
6159            back_timeout: config.back_timeout,
6160            connect_timeout: config.connect_timeout,
6161            zombie_check_interval: config.zombie_check_interval,
6162            accept_queue_timeout: config.accept_queue_timeout,
6163            min_buffers: config.min_buffers,
6164            max_buffers: config.max_buffers,
6165            buffer_size: config.buffer_size,
6166            log_level: config.log_level.clone(),
6167            log_target: config.log_target.clone(),
6168            access_logs_target: config.access_logs_target.clone(),
6169            audit_logs_target: config.audit_logs_target.clone(),
6170            audit_logs_json_target: config.audit_logs_json_target.clone(),
6171            command_buffer_size: config.command_buffer_size,
6172            max_command_buffer_size: config.max_command_buffer_size,
6173            metrics,
6174            access_log_format: ProtobufAccessLogFormat::from(&config.access_logs_format) as i32,
6175            log_colored: config.log_colored,
6176            slab_entries_per_connection: config.slab_entries_per_connection,
6177            basic_auth_max_credential_bytes: config.basic_auth_max_credential_bytes,
6178            evict_on_queue_full: Some(config.evict_on_queue_full),
6179            max_connections_per_ip: Some(config.max_connections_per_ip),
6180            max_connections_per_subnet: Some(config.max_connections_per_subnet),
6181            subnet_ipv4_prefix: Some(config.subnet_ipv4_prefix),
6182            subnet_ipv6_prefix: Some(config.subnet_ipv6_prefix),
6183            retry_after: Some(config.retry_after),
6184            splice_pipe_capacity_bytes: config.splice_pipe_capacity_bytes,
6185            max_connection_attempts: Some(config.max_connection_attempts),
6186        };
6187
6188        // POST: the worker-facing config preserves the buffer-pool invariant
6189        // (min <= max) and carries the sizing knobs through unchanged — a
6190        // worker derives its slab and buffer pool straight from these, so any
6191        // drift here would desynchronize the master's view from the worker's.
6192        debug_assert!(
6193            server_config.min_buffers <= server_config.max_buffers,
6194            "ServerConfig must preserve min_buffers <= max_buffers"
6195        );
6196        debug_assert_eq!(
6197            server_config.buffer_size, config.buffer_size,
6198            "ServerConfig buffer_size must mirror the source config"
6199        );
6200        debug_assert_eq!(
6201            server_config.max_connections, config.max_connections as u64,
6202            "ServerConfig max_connections must mirror the source config"
6203        );
6204        server_config
6205    }
6206}
6207
6208#[cfg(test)]
6209mod tests {
6210    use toml::to_string;
6211
6212    use super::*;
6213
6214    /// `redirect = "silent_close"` parses, in any case, to
6215    /// `RedirectPolicy::SilentClose` (sozu-proxy/sozu#767); the hyphenated
6216    /// spelling is not an alias and is refused like any unknown policy.
6217    #[test]
6218    fn silent_close_redirect_policy_parses_case_insensitively() {
6219        for spelling in ["silent_close", "SILENT_CLOSE", "Silent_Close"] {
6220            assert_eq!(
6221                parse_redirect_policy(spelling).ok(),
6222                Some(RedirectPolicy::SilentClose),
6223                "{spelling} must parse to SilentClose"
6224            );
6225        }
6226        assert!(matches!(
6227            parse_redirect_policy("silent-close"),
6228            Err(ConfigError::InvalidRedirectPolicy(value)) if value == "silent-close"
6229        ));
6230    }
6231
6232    #[test]
6233    fn http_frontend_debug_redacts_pem_material() {
6234        const CERTIFICATE_SECRET: &str = "HTTP_FRONTEND_CERTIFICATE_PEM_SECRET_SENTINEL";
6235        const CHAIN_SECRET: &str = "HTTP_FRONTEND_CHAIN_PEM_SECRET_SENTINEL";
6236        const KEY_SECRET: &str = "HTTP_FRONTEND_KEY_PEM_SECRET_SENTINEL";
6237        const HOSTNAME_SECRET: &str = "HTTP_FRONTEND_HOSTNAME_SECRET_SENTINEL";
6238        const PATH_SECRET: &str = "HTTP_FRONTEND_PATH_SECRET_SENTINEL";
6239        const METHOD_SECRET: &str = "HTTP_FRONTEND_METHOD_SECRET_SENTINEL";
6240        const TAG_KEY_SECRET: &str = "HTTP_FRONTEND_TAG_KEY_SECRET_SENTINEL";
6241        const TAG_SECRET: &str = "HTTP_FRONTEND_TAG_VALUE_SECRET_SENTINEL";
6242        const HEADER_KEY_SECRET: &str = "HTTP_FRONTEND_HEADER_KEY_SECRET_SENTINEL";
6243        const HEADER_SECRET: &str = "HTTP_FRONTEND_HEADER_VALUE_SECRET_SENTINEL";
6244        const REDIRECT_TEMPLATE_SECRET: &str = "HTTP_FRONTEND_REDIRECT_TEMPLATE_SECRET_SENTINEL";
6245        const REWRITE_HOST_SECRET: &str = "HTTP_FRONTEND_REWRITE_HOST_SECRET_SENTINEL";
6246        const REWRITE_PATH_SECRET: &str = "HTTP_FRONTEND_REWRITE_PATH_SECRET_SENTINEL";
6247
6248        let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
6249        let certificate = long_value(CERTIFICATE_SECRET);
6250        let certificate_chain = long_value(CHAIN_SECRET);
6251        let key = long_value(KEY_SECRET);
6252        let hostname = long_value(HOSTNAME_SECRET);
6253        let path = long_value(PATH_SECRET);
6254        let method = long_value(METHOD_SECRET);
6255        let redirect_template = long_value(REDIRECT_TEMPLATE_SECRET);
6256        let rewrite_host = long_value(REWRITE_HOST_SECRET);
6257        let rewrite_path = long_value(REWRITE_PATH_SECRET);
6258
6259        let frontend = HttpFrontendConfig {
6260            address: "127.0.0.1:8443".parse().unwrap(),
6261            hostname,
6262            path: PathRule::prefix(path),
6263            method: Some(method),
6264            certificate: Some(certificate),
6265            key: Some(key),
6266            certificate_chain: Some(vec![certificate_chain]),
6267            tls_versions: vec![TlsVersion::TlsV13],
6268            position: RulePosition::Tree,
6269            tags: Some(BTreeMap::from([(
6270                long_value(TAG_KEY_SECRET),
6271                long_value(TAG_SECRET),
6272            )])),
6273            redirect: None,
6274            redirect_scheme: None,
6275            redirect_template: Some(redirect_template),
6276            rewrite_host: Some(rewrite_host),
6277            rewrite_path: Some(rewrite_path),
6278            rewrite_port: None,
6279            required_auth: None,
6280            headers: vec![Header {
6281                position: HeaderPosition::Request as i32,
6282                key: long_value(HEADER_KEY_SECRET),
6283                val: long_value(HEADER_SECRET),
6284            }],
6285            hsts: None,
6286            header_predicates: Vec::new(),
6287        };
6288
6289        let output = format!("{frontend:?}");
6290
6291        let secrets = [
6292            CERTIFICATE_SECRET,
6293            CHAIN_SECRET,
6294            KEY_SECRET,
6295            HOSTNAME_SECRET,
6296            PATH_SECRET,
6297            METHOD_SECRET,
6298            TAG_KEY_SECRET,
6299            TAG_SECRET,
6300            HEADER_KEY_SECRET,
6301            HEADER_SECRET,
6302            REDIRECT_TEMPLATE_SECRET,
6303            REWRITE_HOST_SECRET,
6304            REWRITE_PATH_SECRET,
6305        ];
6306        for secret in secrets {
6307            assert!(
6308                !output.contains(secret),
6309                "HttpFrontendConfig Debug leaked secret marker {secret}: {output}"
6310            );
6311        }
6312        let expected_metadata = [
6313            "address: 127.0.0.1:8443".to_owned(),
6314            format!("hostname_len: {}", long_value(HOSTNAME_SECRET).len()),
6315            format!("path_kind: {}", PathRule::prefix(String::new()).kind),
6316            format!("path_len: {}", long_value(PATH_SECRET).len()),
6317            format!("method_len: Some({})", long_value(METHOD_SECRET).len()),
6318            "certificate: Some(\"[redacted]\")".to_owned(),
6319            format!(
6320                "certificate_len: Some({})",
6321                long_value(CERTIFICATE_SECRET).len()
6322            ),
6323            "key: Some(\"[redacted]\")".to_owned(),
6324            format!("key_len: Some({})", long_value(KEY_SECRET).len()),
6325            "certificate_chain: Some(\"[redacted]\")".to_owned(),
6326            "certificate_chain_count: Some(1)".to_owned(),
6327            format!(
6328                "certificate_chain_len: Some({})",
6329                long_value(CHAIN_SECRET).len()
6330            ),
6331            "tls_versions_count: 1".to_owned(),
6332            "tags_count: 1".to_owned(),
6333            format!(
6334                "redirect_template_len: Some({})",
6335                long_value(REDIRECT_TEMPLATE_SECRET).len()
6336            ),
6337            format!(
6338                "rewrite_host_len: Some({})",
6339                long_value(REWRITE_HOST_SECRET).len()
6340            ),
6341            format!(
6342                "rewrite_path_len: Some({})",
6343                long_value(REWRITE_PATH_SECRET).len()
6344            ),
6345            "headers_count: 1".to_owned(),
6346        ];
6347        for safe_metadata in expected_metadata {
6348            assert!(
6349                output.contains(&safe_metadata),
6350                "HttpFrontendConfig Debug omitted safe metadata {safe_metadata}: {output}"
6351            );
6352        }
6353        assert!(
6354            output.len() <= 1024,
6355            "HttpFrontendConfig Debug output is not bounded: {} bytes",
6356            output.len()
6357        );
6358
6359        for (index, request) in frontend
6360            .generate_requests("safe-cluster-id")
6361            .into_iter()
6362            .enumerate()
6363        {
6364            let generated_output = format!("{request:?}");
6365            for secret in secrets {
6366                assert!(
6367                    !generated_output.contains(secret),
6368                    "generated frontend request {index} Debug leaked secret marker {secret}: {generated_output}"
6369                );
6370            }
6371            assert!(
6372                generated_output.len() <= 2048,
6373                "generated frontend request {index} Debug output is not bounded: {} bytes",
6374                generated_output.len()
6375            );
6376        }
6377    }
6378
6379    #[test]
6380    fn hsts_to_proto_enabled_substitutes_default_max_age() {
6381        let cfg = FileHstsConfig {
6382            enabled: Some(true),
6383            max_age: None,
6384            include_subdomains: None,
6385            preload: None,
6386            force_replace_backend: None,
6387        };
6388        let proto = cfg.to_proto("test").expect("should validate");
6389        assert_eq!(proto.enabled, Some(true));
6390        assert_eq!(proto.max_age, Some(DEFAULT_HSTS_MAX_AGE));
6391    }
6392
6393    #[test]
6394    fn hsts_to_proto_explicit_max_age_kept() {
6395        let cfg = FileHstsConfig {
6396            enabled: Some(true),
6397            max_age: Some(63_072_000),
6398            include_subdomains: Some(true),
6399            preload: Some(true),
6400            force_replace_backend: None,
6401        };
6402        let proto = cfg.to_proto("test").expect("should validate");
6403        assert_eq!(proto.max_age, Some(63_072_000));
6404        assert_eq!(proto.include_subdomains, Some(true));
6405        assert_eq!(proto.preload, Some(true));
6406    }
6407
6408    #[test]
6409    fn hsts_to_proto_disabled_keeps_zero_intent() {
6410        // `enabled = false` means "explicit disable" — the materialiser
6411        // in `Frontend::new` won't append an edit, so the proto still
6412        // round-trips with `enabled = Some(false)`.
6413        let cfg = FileHstsConfig {
6414            enabled: Some(false),
6415            max_age: None,
6416            include_subdomains: None,
6417            preload: None,
6418            force_replace_backend: None,
6419        };
6420        let proto = cfg.to_proto("test").expect("should validate");
6421        assert_eq!(proto.enabled, Some(false));
6422    }
6423
6424    #[test]
6425    fn hsts_to_proto_kill_switch_max_age_zero_allowed() {
6426        // RFC 6797 §11.4: `max-age=0` instructs the UA to "cease
6427        // regarding the host as a Known HSTS Host". Explicit operator
6428        // intent — must NOT warn or fail.
6429        let cfg = FileHstsConfig {
6430            enabled: Some(true),
6431            max_age: Some(0),
6432            include_subdomains: None,
6433            preload: None,
6434            force_replace_backend: None,
6435        };
6436        let proto = cfg.to_proto("test").expect("kill-switch must validate");
6437        assert_eq!(proto.max_age, Some(0));
6438    }
6439
6440    #[test]
6441    fn hsts_to_proto_missing_enabled_errors() {
6442        let cfg = FileHstsConfig {
6443            enabled: None,
6444            max_age: Some(31_536_000),
6445            include_subdomains: None,
6446            preload: None,
6447            force_replace_backend: None,
6448        };
6449        match cfg.to_proto("test").unwrap_err() {
6450            ConfigError::HstsEnabledRequired(scope) => assert_eq!(scope, "test"),
6451            other => panic!("expected HstsEnabledRequired, got {other:?}"),
6452        }
6453    }
6454
6455    #[test]
6456    fn hsts_rejected_on_http_listener() {
6457        // RFC 6797 §7.2: an [hsts] block on an HTTP listener must be
6458        // rejected at TOML config-load — `HttpListenerConfig` carries no
6459        // `hsts` field and silently dropping the operator's intent
6460        // would be a worse failure mode than a typed error.
6461        let mut listener = ListenerBuilder::new(
6462            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6463            ListenerProtocol::Http,
6464        );
6465        listener.hsts = Some(FileHstsConfig {
6466            enabled: Some(true),
6467            max_age: Some(31_536_000),
6468            include_subdomains: None,
6469            preload: None,
6470            force_replace_backend: None,
6471        });
6472        match listener.to_http(None).unwrap_err() {
6473            ConfigError::HstsOnPlainHttp(scope) => assert!(
6474                scope.contains("HTTP listener"),
6475                "expected scope to mention 'HTTP listener', got {scope:?}"
6476            ),
6477            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
6478        }
6479    }
6480
6481    /// Regression for sozu-proxy/sozu#1418: an H2 flood threshold of `0` set
6482    /// in the config file reached `HttpListenerConfig` untouched. From there
6483    /// `lib/src/http.rs::get_h2_flood_config` handed it to
6484    /// `H2FloodDetector::new`, whose `check_flood` compares `count >
6485    /// threshold` — so the first counted RST_STREAM of every H2 connection
6486    /// tripped the detector and the listener answered `ENHANCE_YOUR_CALM` to
6487    /// every client. `UpdateHttpListener` already refused the same value
6488    /// (`validate_h2_flood_knobs_http`); the config-file path did not.
6489    #[test]
6490    fn h2_flood_threshold_zero_rejected_on_http_listener() {
6491        let mut listener = ListenerBuilder::new(
6492            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6493            ListenerProtocol::Http,
6494        );
6495        listener.h2_max_rst_stream_per_window = Some(0);
6496        match listener.to_http(None).unwrap_err() {
6497            ConfigError::H2ThresholdBelowMinimum {
6498                key,
6499                value,
6500                minimum,
6501                ..
6502            } => {
6503                assert_eq!(key, "h2_max_rst_stream_per_window");
6504                assert_eq!(value, 0);
6505                assert_eq!(minimum, 1);
6506            }
6507            other => panic!("expected H2ThresholdBelowMinimum, got {other:?}"),
6508        }
6509    }
6510
6511    /// The HTTPS listener path (`to_tls`) carries the same knobs and needs the
6512    /// same gate (sozu-proxy/sozu#1418). A lifetime cap is used here rather
6513    /// than a per-window one so the `u64` knobs are covered too.
6514    #[test]
6515    fn h2_flood_threshold_zero_rejected_on_https_listener() {
6516        let mut listener = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
6517        listener.h2_max_rst_stream_abusive_lifetime = Some(0);
6518        match listener.to_tls(None).unwrap_err() {
6519            ConfigError::H2ThresholdBelowMinimum {
6520                key,
6521                value,
6522                minimum,
6523                ..
6524            } => {
6525                assert_eq!(key, "h2_max_rst_stream_abusive_lifetime");
6526                assert_eq!(value, 0);
6527                assert_eq!(minimum, 1);
6528            }
6529            other => panic!("expected H2ThresholdBelowMinimum, got {other:?}"),
6530        }
6531    }
6532
6533    /// `h2_stream_shrink_ratio` has a runtime floor of 2, not 1
6534    /// (`lib/src/protocol/mux/h2.rs`'s `.max(2)`), and
6535    /// `validate_h2_flood_knobs_http` rejects `< 2` on the update path. The
6536    /// config-file path must agree (sozu-proxy/sozu#1418).
6537    #[test]
6538    fn h2_stream_shrink_ratio_below_two_rejected_on_http_listener() {
6539        let mut listener = ListenerBuilder::new(
6540            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6541            ListenerProtocol::Http,
6542        );
6543        listener.h2_stream_shrink_ratio = Some(1);
6544        match listener.to_http(None).unwrap_err() {
6545            ConfigError::H2ThresholdBelowMinimum {
6546                key,
6547                value,
6548                minimum,
6549                ..
6550            } => {
6551                assert_eq!(key, "h2_stream_shrink_ratio");
6552                assert_eq!(value, 1);
6553                assert_eq!(minimum, 2);
6554            }
6555            other => panic!("expected H2ThresholdBelowMinimum, got {other:?}"),
6556        }
6557    }
6558
6559    /// The boundary itself must load: every documented minimum is an accepted
6560    /// value, not a rejected one.
6561    #[test]
6562    fn h2_thresholds_at_their_minimum_accepted() {
6563        let mut listener = ListenerBuilder::new(
6564            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6565            ListenerProtocol::Http,
6566        );
6567        listener.h2_max_rst_stream_per_window = Some(1);
6568        listener.h2_max_rst_stream_abusive_lifetime = Some(1);
6569        listener.h2_stream_shrink_ratio = Some(2);
6570        let config = listener
6571            .to_http(None)
6572            .expect("thresholds at their minimum must be accepted");
6573        assert_eq!(config.h2_max_rst_stream_per_window, Some(1));
6574        assert_eq!(config.h2_max_rst_stream_abusive_lifetime, Some(1));
6575        assert_eq!(config.h2_stream_shrink_ratio, Some(2));
6576    }
6577
6578    #[test]
6579    fn hsts_rejected_on_http_frontend() {
6580        // A `FileClusterFrontendConfig` without a key+certificate pair
6581        // generates `RequestType::AddHttpFrontend` in
6582        // `HttpFrontendConfig::generate_requests`. RFC 6797 §7.2 forbids
6583        // HSTS on plaintext HTTP, so an `[hsts]` block on a cert-less
6584        // (HTTP-bound) frontend must be rejected at TOML config-load.
6585        let frontend = FileClusterFrontendConfig {
6586            address: "127.0.0.1:8080".parse().unwrap(),
6587            hostname: Some("example.com".to_owned()),
6588            alpn: vec![],
6589            path: None,
6590            path_type: None,
6591            method: None,
6592            certificate: None,
6593            key: None,
6594            certificate_chain: None,
6595            tls_versions: vec![],
6596            position: RulePosition::Tree,
6597            tags: None,
6598            redirect: None,
6599            redirect_scheme: None,
6600            redirect_template: None,
6601            rewrite_host: None,
6602            rewrite_path: None,
6603            rewrite_port: None,
6604            required_auth: None,
6605            headers: None,
6606            hsts: Some(FileHstsConfig {
6607                enabled: Some(true),
6608                max_age: Some(31_536_000),
6609                include_subdomains: None,
6610                preload: None,
6611                force_replace_backend: None,
6612            }),
6613            header_predicates: None,
6614        };
6615        match frontend.to_http_front("api").unwrap_err() {
6616            ConfigError::HstsOnPlainHttp(scope) => {
6617                assert!(
6618                    scope.contains("api") && scope.contains("example.com"),
6619                    "expected scope to mention 'api' and 'example.com', got {scope:?}"
6620                );
6621            }
6622            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
6623        }
6624    }
6625
6626    /// `path_type = "REGEX_PATH"` (sozu-proxy/sozu#1909) parses to the
6627    /// path-only regex kind, distinct from `"REGEX"`, and a spelling the
6628    /// enum does not know is still refused.
6629    #[test]
6630    fn path_type_regex_path_parses_to_the_path_only_regex_kind() {
6631        let parse = |path_type: &str| {
6632            toml::from_str::<FileClusterFrontendConfig>(&format!(
6633                "address = \"127.0.0.1:8080\"\n\
6634                 hostname = \"example.com\"\n\
6635                 path = \"^/api/v2$\"\n\
6636                 path_type = \"{path_type}\"\n"
6637            ))
6638        };
6639        let frontend = parse("REGEX_PATH").expect("REGEX_PATH must parse");
6640        assert_eq!(frontend.path_type, Some(PathRuleType::RegexPath));
6641        let http = frontend
6642            .to_http_front("api")
6643            .expect("a REGEX_PATH frontend must convert");
6644        assert_eq!(http.path, PathRule::regex_path("^/api/v2$"));
6645        assert_eq!(
6646            crate::proto::command::PathRuleKind::try_from(http.path.kind),
6647            Ok(crate::proto::command::PathRuleKind::RegexPath)
6648        );
6649        assert_ne!(
6650            http.path,
6651            PathRule::regex("^/api/v2$"),
6652            "REGEX_PATH must not collapse into REGEX"
6653        );
6654
6655        let regex = parse("REGEX").expect("REGEX must still parse");
6656        assert_eq!(regex.path_type, Some(PathRuleType::Regex));
6657        assert!(
6658            parse("PATH_REGEX").is_err(),
6659            "an unknown path_type is refused"
6660        );
6661    }
6662
6663    #[test]
6664    fn serialize() {
6665        let http = ListenerBuilder::new(
6666            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6667            ListenerProtocol::Http,
6668        )
6669        .with_answer_404_path(Some("404.html"))
6670        .to_owned();
6671        println!("http: {:?}", to_string(&http));
6672
6673        let https = ListenerBuilder::new(
6674            SocketAddress::new_v4(127, 0, 0, 1, 8443),
6675            ListenerProtocol::Https,
6676        )
6677        .with_answer_404_path(Some("404.html"))
6678        .to_owned();
6679        println!("https: {:?}", to_string(&https));
6680
6681        let listeners = vec![http, https];
6682        let config = FileConfig {
6683            command_socket: Some(String::from("./command_folder/sock")),
6684            worker_count: Some(2),
6685            worker_automatic_restart: Some(true),
6686            max_connections: Some(500),
6687            min_buffers: Some(1),
6688            max_buffers: Some(500),
6689            buffer_size: Some(16393),
6690            metrics: Some(MetricsConfig {
6691                address: "127.0.0.1:8125".parse().unwrap(),
6692                tagged_metrics: false,
6693                prefix: Some(String::from("sozu-metrics")),
6694                detail: MetricDetailLevel::default(),
6695            }),
6696            listeners: Some(listeners),
6697            ..Default::default()
6698        };
6699
6700        println!("config: {:?}", to_string(&config));
6701        let encoded = to_string(&config).unwrap();
6702        println!("conf:\n{encoded}");
6703    }
6704
6705    #[test]
6706    fn parse() {
6707        let path = "assets/config.toml";
6708        let config = Config::load_from_path(path).unwrap_or_else(|load_error| {
6709            panic!("Cannot load config from path {path}: {load_error:?}")
6710        });
6711        println!("config: {config:#?}");
6712        //panic!();
6713    }
6714
6715    #[test]
6716    fn multiple_listeners_preserve_per_address_expect_proxy() {
6717        let toml_content = r#"
6718            command_socket = "/tmp/sozu_test.sock"
6719            worker_count = 1
6720
6721            [[listeners]]
6722            protocol = "http"
6723            address = "172.16.20.1:80"
6724            expect_proxy = true
6725
6726            [[listeners]]
6727            protocol = "http"
6728            address = "10.22.0.1:80"
6729            expect_proxy = false
6730
6731            [[listeners]]
6732            protocol = "https"
6733            address = "192.168.1.1:443"
6734            expect_proxy = true
6735
6736            [[listeners]]
6737            protocol = "https"
6738            address = "192.168.2.1:443"
6739            expect_proxy = false
6740        "#;
6741
6742        let file_config: FileConfig =
6743            toml::from_str(toml_content).expect("Could not parse TOML config");
6744
6745        let listeners = file_config.listeners.as_ref().expect("No listeners found");
6746        assert_eq!(listeners.len(), 4);
6747
6748        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
6749            .into_config()
6750            .expect("Could not build config");
6751
6752        assert_eq!(config.http_listeners.len(), 2);
6753        assert_eq!(config.https_listeners.len(), 2);
6754
6755        // HTTP listeners
6756        let http_proxy = config
6757            .http_listeners
6758            .iter()
6759            .find(|l| SocketAddr::from(l.address) == "172.16.20.1:80".parse().unwrap())
6760            .expect("Listener on 172.16.20.1:80 not found");
6761        let http_direct = config
6762            .http_listeners
6763            .iter()
6764            .find(|l| SocketAddr::from(l.address) == "10.22.0.1:80".parse().unwrap())
6765            .expect("Listener on 10.22.0.1:80 not found");
6766
6767        assert!(http_proxy.expect_proxy);
6768        assert!(!http_direct.expect_proxy);
6769
6770        // HTTPS listeners
6771        let https_proxy = config
6772            .https_listeners
6773            .iter()
6774            .find(|l| SocketAddr::from(l.address) == "192.168.1.1:443".parse().unwrap())
6775            .expect("Listener on 192.168.1.1:443 not found");
6776        let https_direct = config
6777            .https_listeners
6778            .iter()
6779            .find(|l| SocketAddr::from(l.address) == "192.168.2.1:443".parse().unwrap())
6780            .expect("Listener on 192.168.2.1:443 not found");
6781
6782        assert!(https_proxy.expect_proxy);
6783        assert!(!https_direct.expect_proxy);
6784    }
6785
6786    /// sozu-proxy/sozu#1911: `h2c_prior_knowledge` reaches the HTTP listener
6787    /// from TOML, and a listener that does not name the key carries none on
6788    /// the wire — exactly what it serialized to before the key existed.
6789    #[test]
6790    fn h2c_prior_knowledge_is_parsed_from_toml_and_absent_when_unset() {
6791        let toml_content = r#"
6792            [[listeners]]
6793            protocol = "http"
6794            address = "127.0.0.1:8080"
6795            h2c_prior_knowledge = true
6796
6797            [[listeners]]
6798            protocol = "http"
6799            address = "127.0.0.1:8081"
6800            h2c_prior_knowledge = false
6801
6802            [[listeners]]
6803            protocol = "http"
6804            address = "127.0.0.1:8082"
6805        "#;
6806
6807        let file_config: FileConfig =
6808            toml::from_str(toml_content).expect("Could not parse TOML config");
6809        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
6810            .into_config()
6811            .expect("Could not build config");
6812
6813        let knob = |address: &str| {
6814            config
6815                .http_listeners
6816                .iter()
6817                .find(|l| SocketAddr::from(l.address) == address.parse().unwrap())
6818                .unwrap_or_else(|| panic!("listener on {address} not found"))
6819                .h2c_prior_knowledge
6820        };
6821        assert_eq!(knob("127.0.0.1:8080"), Some(true));
6822        assert_eq!(knob("127.0.0.1:8081"), Some(false));
6823        assert_eq!(knob("127.0.0.1:8082"), None);
6824    }
6825
6826    /// #347: builds the HTTP cluster `cluster` from a TOML backend list.
6827    fn http_cluster_from_backends(backends: &str) -> Result<HttpClusterConfig, ConfigError> {
6828        let file: FileClusterConfig = toml::from_str(&format!(
6829            "protocol = \"http\"\nfrontends = []\nbackends = [{backends}]\n"
6830        ))
6831        .expect("the cluster must parse");
6832        match file.to_cluster_config("cluster", &HashSet::new())? {
6833            ClusterConfig::Http(http) => Ok(http),
6834            ClusterConfig::Tcp(_) => panic!("an http cluster must build as one"),
6835        }
6836    }
6837
6838    /// #347: a `host:port` backend address is resolved when the file is
6839    /// loaded, into loopback backends with ids derived from the entry and
6840    /// the address, and the same file always yields the same backends.
6841    #[test]
6842    fn a_backend_hostname_resolves_when_the_file_is_loaded() {
6843        let backends = r#"{ address = "localhost:8080", weight = 50 }"#;
6844        let http = http_cluster_from_backends(backends).expect("localhost must resolve");
6845
6846        assert!(
6847            !http.backends.is_empty(),
6848            "localhost resolves to an address"
6849        );
6850        let mut ids = HashSet::new();
6851        for backend in &http.backends {
6852            assert!(backend.address.ip().is_loopback(), "{backend:?}");
6853            assert_eq!(backend.address.port(), 8080);
6854            assert_eq!(backend.weight, Some(50));
6855            assert_eq!(
6856                backend.backend_id.as_deref(),
6857                Some(format!("cluster-localhost:8080-{}", backend.address).as_str())
6858            );
6859            assert!(ids.insert(backend.backend_id.clone()), "ids are distinct");
6860        }
6861        assert_eq!(
6862            http_cluster_from_backends(backends)
6863                .expect("localhost must resolve")
6864                .backends,
6865            http.backends,
6866            "the same file yields the same backends"
6867        );
6868
6869        let orders = http.generate_requests().expect("the orders must build");
6870        let added: Vec<&AddBackend> = orders
6871            .iter()
6872            .filter_map(|order| match order.request_type.as_ref() {
6873                Some(RequestType::AddBackend(add)) => Some(add),
6874                _ => None,
6875            })
6876            .collect();
6877        assert_eq!(added.len(), http.backends.len());
6878        for (add, backend) in added.iter().zip(&http.backends) {
6879            assert_eq!(Some(&add.backend_id), backend.backend_id.as_ref());
6880            assert_eq!(SocketAddr::from(add.address), backend.address);
6881        }
6882    }
6883
6884    /// #347: a socket-address literal never reaches the resolver and keeps
6885    /// the positional default id, so existing files load unchanged.
6886    #[test]
6887    fn a_literal_backend_address_keeps_its_positional_id() {
6888        let http = http_cluster_from_backends(
6889            r#"{ address = "127.0.0.1:8080" }, { address = "[::1]:8081", backend_id = "six" }"#,
6890        )
6891        .expect("literals must build");
6892        assert_eq!(
6893            http.backends,
6894            vec![
6895                BackendConfig {
6896                    address: "127.0.0.1:8080".parse().unwrap(),
6897                    weight: None,
6898                    sticky_id: None,
6899                    backup: None,
6900                    backend_id: Some("cluster-0-127.0.0.1:8080".to_owned()),
6901                    unix_path: None,
6902                },
6903                BackendConfig {
6904                    address: "[::1]:8081".parse().unwrap(),
6905                    weight: None,
6906                    sticky_id: None,
6907                    backup: None,
6908                    backend_id: Some("six".to_owned()),
6909                    unix_path: None,
6910                },
6911            ]
6912        );
6913    }
6914
6915    /// #347: a literal keeps the id of its position in the file, whatever
6916    /// the number of addresses a name before it resolved to, on the HTTP and
6917    /// the TCP path alike. The resolver is injected, so the answer size does
6918    /// not depend on the host.
6919    #[test]
6920    fn a_literal_after_a_name_keeps_its_file_position_id() {
6921        let one: SocketAddr = "10.0.0.2:8080".parse().unwrap();
6922        let two: SocketAddr = "10.0.0.3:8080".parse().unwrap();
6923        let answer = |addresses: Vec<SocketAddr>| {
6924            move |_: &str| -> std::io::Result<Vec<SocketAddr>> { Ok(addresses.clone()) }
6925        };
6926
6927        for (protocol, addresses) in [
6928            ("http", vec![one]),
6929            ("http", vec![one, two]),
6930            ("tcp", vec![one]),
6931            ("tcp", vec![one, two]),
6932        ] {
6933            let resolver = answer(addresses.clone());
6934            let file: FileClusterConfig = toml::from_str(&format!(
6935                "protocol = \"{protocol}\"\nfrontends = []\n\
6936                 backends = [{{ address = \"name:8080\" }}, {{ address = \"127.0.0.1:9000\" }}]\n"
6937            ))
6938            .expect("the cluster must parse");
6939            let backends = match file
6940                .to_cluster_config_with("cluster", &HashSet::new(), Some(&resolver))
6941                .expect("the cluster must build")
6942            {
6943                ClusterConfig::Http(http) => http.backends,
6944                ClusterConfig::Tcp(tcp) => tcp.backends,
6945            };
6946            assert_eq!(backends.len(), addresses.len() + 1, "{protocol}");
6947            assert_eq!(
6948                backends.last().and_then(|b| b.backend_id.as_deref()),
6949                Some("cluster-1-127.0.0.1:9000"),
6950                "{protocol} with {} resolved address(es)",
6951                addresses.len()
6952            );
6953        }
6954
6955        let error = toml::from_str::<FileClusterConfig>(
6956            "protocol = \"tcp\"\nfrontends = []\nbackends = [{ address = \"localhost\" }]\n",
6957        )
6958        .expect("the cluster must parse")
6959        .to_cluster_config("cluster", &HashSet::new())
6960        .expect_err("a missing port refuses the tcp cluster");
6961        assert!(matches!(
6962            error,
6963            ConfigError::BackendAddressResolution { .. }
6964        ));
6965    }
6966
6967    /// #347: digit-and-dot hosts that are not an IP literal are refused
6968    /// before the resolver, which would read them as legacy numeric
6969    /// shorthand (`010.0.0.1` as `8.0.0.1`, `1.2.3` as `1.2.0.3`).
6970    #[test]
6971    fn numeric_shorthand_backend_hosts_never_reach_the_resolver() {
6972        let shorthand = |_: &str| -> std::io::Result<Vec<SocketAddr>> {
6973            Ok(vec!["8.0.0.1:8080".parse().unwrap()])
6974        };
6975        for address in [
6976            "010.0.0.1:8080",
6977            "1.2.3:8080",
6978            "2130706433:8080",
6979            "0x7f000001:8080",
6980            "0x7f.1:8080",
6981            "0X7F.0.0.1:8080",
6982            "0177.1:8080",
6983            "0x7f.0177.1:8080",
6984        ] {
6985            let entry = FileBackendConfig {
6986                address: address.to_owned(),
6987                weight: None,
6988                sticky_id: None,
6989                backup: None,
6990                backend_id: None,
6991                unix_path: None,
6992            };
6993            let error = entry
6994                .resolve("cluster", 0, &shorthand)
6995                .expect_err("numeric shorthand must be refused");
6996            let ConfigError::BackendAddressResolution {
6997                cluster_id,
6998                address: entry_address,
6999                ..
7000            } = &error
7001            else {
7002                panic!("{address}: unexpected error {error:?}");
7003            };
7004            assert_eq!(cluster_id, "cluster");
7005            assert_eq!(entry_address, address);
7006        }
7007    }
7008
7009    /// #347: an IPv6 literal must be bracketed (`[addr]:port`); the
7010    /// unbracketed forms are refused before the resolver, which splits on
7011    /// the last colon and would accept them numerically. The bracketed form
7012    /// still parses directly.
7013    #[test]
7014    fn unbracketed_ipv6_backend_hosts_never_reach_the_resolver() {
7015        let unreachable = |address: &str| -> std::io::Result<Vec<SocketAddr>> {
7016            panic!("{address}: must not reach the resolver")
7017        };
7018        for address in ["::1:8080", "2001:db8::1:8080", "fe80::1%eth0:80"] {
7019            let entry = FileBackendConfig {
7020                address: address.to_owned(),
7021                weight: None,
7022                sticky_id: None,
7023                backup: None,
7024                backend_id: None,
7025                unix_path: None,
7026            };
7027            let error = entry
7028                .resolve("cluster", 0, &unreachable)
7029                .expect_err("an unbracketed IPv6 literal must be refused");
7030            let ConfigError::BackendAddressResolution {
7031                cluster_id,
7032                address: entry_address,
7033                reason,
7034            } = &error
7035            else {
7036                panic!("{address}: unexpected error {error:?}");
7037            };
7038            assert_eq!(cluster_id, "cluster");
7039            assert_eq!(entry_address, address);
7040            assert!(reason.contains("[address]:port"), "{address}: {reason}");
7041        }
7042
7043        let entry = FileBackendConfig {
7044            address: "[2001:db8::1]:8080".to_owned(),
7045            weight: Some(3),
7046            sticky_id: Some("sticky".to_owned()),
7047            backup: Some(true),
7048            backend_id: None,
7049            unix_path: None,
7050        };
7051        let backends = entry
7052            .resolve("cluster", 2, &unreachable)
7053            .expect("a bracketed IPv6 literal parses directly");
7054        let address: SocketAddr = "[2001:db8::1]:8080".parse().unwrap();
7055        assert_eq!(backends.len(), 1);
7056        assert_eq!(backends[0].address, address);
7057        assert_eq!(backends[0].weight, Some(3));
7058        assert_eq!(backends[0].sticky_id.as_deref(), Some("sticky"));
7059        assert_eq!(backends[0].backup, Some(true));
7060        assert_eq!(
7061            backends[0].backend_id.as_deref(),
7062            Some("cluster-2-[2001:db8::1]:8080")
7063        );
7064    }
7065
7066    /// #347: names that merely contain digits or hex-looking labels still
7067    /// reach the resolver.
7068    #[test]
7069    fn benign_backend_hostnames_are_not_numeric_shorthand() {
7070        let resolved = "10.0.0.2:8080".parse::<SocketAddr>().unwrap();
7071        let resolver = |_: &str| -> std::io::Result<Vec<SocketAddr>> { Ok(vec![resolved]) };
7072        for address in [
7073            "123.example.com:8080",
7074            "dead.beef:8080",
7075            "0x.example.com:8080",
7076            "1.2.3.example:8080",
7077            "localhost:8080",
7078        ] {
7079            let entry = FileBackendConfig {
7080                address: address.to_owned(),
7081                weight: None,
7082                sticky_id: None,
7083                backup: None,
7084                backend_id: None,
7085                unix_path: None,
7086            };
7087            let backends = entry
7088                .resolve("cluster", 0, &resolver)
7089                .unwrap_or_else(|error| panic!("{address}: {error:?}"));
7090            assert_eq!(backends.len(), 1, "{address}");
7091        }
7092        assert!(!is_legacy_numeric_host("dead.beef"));
7093        assert!(!is_legacy_numeric_host("1..2"));
7094        assert!(is_legacy_numeric_host("0x7f.1"));
7095    }
7096
7097    /// #347: the control commands load the file without resolving backend
7098    /// names; the startup and `config check` load still refuses it. `1.2.3`
7099    /// is refused before any DNS query, so the test needs no network.
7100    #[test]
7101    fn a_control_load_does_not_resolve_backend_names() {
7102        let dir = tempfile::tempdir().expect("tempdir");
7103        let path = dir.path().join("config.toml");
7104        std::fs::write(
7105            &path,
7106            "command_socket = \"/tmp/sozu_test.sock\"\n\
7107             ctl_command_timeout = 4242\n\
7108             [clusters.app]\n\
7109             protocol = \"http\"\n\
7110             frontends = [{ address = \"127.0.0.1:8080\", hostname = \"example.org\" }]\n\
7111             backends = [{ address = \"1.2.3:8080\" }, { address = \"127.0.0.1:9000\" }]\n",
7112        )
7113        .expect("write config");
7114        let path = path.to_str().expect("utf-8 path");
7115
7116        assert!(matches!(
7117            Config::load_from_path(path),
7118            Err(ConfigError::BackendAddressResolution { .. })
7119        ));
7120
7121        let config = Config::load_from_path_for_control(path).expect("control load");
7122        assert_eq!(config.ctl_command_timeout, 4242);
7123        let Some(ClusterConfig::Http(app)) = config.clusters.get("app") else {
7124            panic!("the cluster must still be validated and built");
7125        };
7126        assert_eq!(app.backends.len(), 1, "only the literal is kept");
7127    }
7128
7129    /// #347: an address that is neither a socket address nor a resolvable
7130    /// `host:port` refuses the file with an error naming the cluster and the
7131    /// entry.
7132    #[test]
7133    fn an_unresolvable_backend_address_is_a_config_error() {
7134        for address in ["localhost", "localhost:notaport"] {
7135            let error = http_cluster_from_backends(&format!(r#"{{ address = "{address}" }}"#))
7136                .expect_err("the address must not resolve");
7137            let ConfigError::BackendAddressResolution {
7138                cluster_id,
7139                address: entry,
7140                ..
7141            } = &error
7142            else {
7143                panic!("{address}: unexpected error {error:?}");
7144            };
7145            assert_eq!(cluster_id, "cluster");
7146            assert_eq!(entry, address);
7147            assert!(error.to_string().contains(address), "{error}");
7148        }
7149    }
7150
7151    /// #152 + #347: every address a name expands to, and a socket-address
7152    /// literal, keeps the entry's `unix_path`.
7153    #[test]
7154    fn every_expanded_backend_keeps_its_unix_path() {
7155        let entry = |address: &str| FileBackendConfig {
7156            address: address.to_owned(),
7157            weight: None,
7158            sticky_id: None,
7159            backup: None,
7160            backend_id: None,
7161            unix_path: Some("/run/app.sock".to_owned()),
7162        };
7163        let v4: SocketAddr = "10.0.0.2:8080".parse().unwrap();
7164        let v6: SocketAddr = "[fd00::1]:8080".parse().unwrap();
7165        let expanded = entry("ingress:8080")
7166            .expand("cluster", vec![v4, v6])
7167            .expect("two addresses expand");
7168        assert_eq!(expanded.len(), 2);
7169        assert!(
7170            expanded
7171                .iter()
7172                .all(|backend| backend.unix_path.as_deref() == Some("/run/app.sock"))
7173        );
7174
7175        let literal = entry("127.0.0.1:8080")
7176            .resolve("cluster", 0, &system_resolver)
7177            .expect("a literal resolves");
7178        assert_eq!(literal[0].unix_path.as_deref(), Some("/run/app.sock"));
7179    }
7180
7181    /// #347: a name resolving to several addresses yields one backend per
7182    /// distinct address, sorted, each with a distinct id and, when one is
7183    /// configured, a distinct sticky id.
7184    #[test]
7185    fn a_name_with_several_addresses_expands_into_distinct_backends() {
7186        let entry = FileBackendConfig {
7187            address: "ingress:8080".to_owned(),
7188            weight: None,
7189            sticky_id: Some("pinned".to_owned()),
7190            backup: Some(true),
7191            backend_id: Some("ingress".to_owned()),
7192            unix_path: None,
7193        };
7194        let v4: SocketAddr = "10.0.0.2:8080".parse().unwrap();
7195        let v6: SocketAddr = "[fd00::1]:8080".parse().unwrap();
7196        let backends = entry
7197            .expand("cluster", vec![v6, v4, v6])
7198            .expect("two addresses expand");
7199        assert_eq!(
7200            backends,
7201            vec![
7202                BackendConfig {
7203                    address: v4,
7204                    weight: None,
7205                    sticky_id: Some(format!("pinned-{v4}")),
7206                    backup: Some(true),
7207                    backend_id: Some(format!("ingress-{v4}")),
7208                    unix_path: None,
7209                },
7210                BackendConfig {
7211                    address: v6,
7212                    weight: None,
7213                    sticky_id: Some(format!("pinned-{v6}")),
7214                    backup: Some(true),
7215                    backend_id: Some(format!("ingress-{v6}")),
7216                    unix_path: None,
7217                },
7218            ]
7219        );
7220
7221        // The sticky id does not depend on how many addresses came back.
7222        let single = entry.expand("cluster", vec![v4]).expect("one address");
7223        assert_eq!(single[0].sticky_id, Some(format!("pinned-{v4}")));
7224        assert!(matches!(
7225            entry.expand("cluster", Vec::new()),
7226            Err(ConfigError::BackendAddressResolution { .. })
7227        ));
7228    }
7229
7230    /// #524: `affinity_header` travels from the TOML file to the
7231    /// `AddCluster` order the workers receive.
7232    #[test]
7233    fn an_affinity_header_reaches_the_add_cluster_order() {
7234        let file: FileClusterConfig = toml::from_str(
7235            r#"
7236            protocol = "http"
7237            load_balancing = "HRW"
7238            affinity_header = "X-Tenant"
7239            frontends = []
7240            backends = []
7241            "#,
7242        )
7243        .expect("the cluster must parse");
7244        let ClusterConfig::Http(http) = file
7245            .to_cluster_config("keyed", &HashSet::new())
7246            .expect("the cluster must build")
7247        else {
7248            panic!("an http cluster must build as one");
7249        };
7250        let orders = http.generate_requests().expect("the orders must build");
7251        let Some(RequestType::AddCluster(cluster)) =
7252            orders.first().and_then(|order| order.request_type.as_ref())
7253        else {
7254            panic!("the orders must lead with an AddCluster");
7255        };
7256        assert_eq!(cluster.affinity_header.as_deref(), Some("X-Tenant"));
7257        assert_eq!(cluster.affinity_cookie, None);
7258    }
7259
7260    /// #152: a backend's `unix_path` travels from the TOML file to the
7261    /// `AddUnixBackend` order, next to the identity `address`; a backend
7262    /// without one keeps `AddBackend`.
7263    #[test]
7264    fn a_unix_path_backend_reaches_the_add_unix_backend_order() {
7265        let file: FileClusterConfig = toml::from_str(
7266            r#"
7267            protocol = "http"
7268            frontends = []
7269            backends = [
7270                { address = "127.0.0.1:1", unix_path = "/run/app.sock" },
7271                { address = "127.0.0.1:2" },
7272            ]
7273            "#,
7274        )
7275        .expect("the cluster must parse");
7276        let ClusterConfig::Http(http) = file
7277            .to_cluster_config("app", &HashSet::new())
7278            .expect("the cluster must build")
7279        else {
7280            panic!("an http cluster must build as one");
7281        };
7282        let unix_paths: Vec<_> = http
7283            .generate_requests()
7284            .expect("the orders must build")
7285            .into_iter()
7286            .filter_map(|order| match order.request_type {
7287                Some(RequestType::AddUnixBackend(backend)) => {
7288                    Some(("AddUnixBackend", backend.unix_path))
7289                }
7290                Some(RequestType::AddBackend(backend)) => Some(("AddBackend", backend.unix_path)),
7291                _ => None,
7292            })
7293            .collect();
7294        assert_eq!(
7295            unix_paths,
7296            vec![
7297                ("AddUnixBackend", Some("/run/app.sock".to_owned())),
7298                ("AddBackend", None)
7299            ]
7300        );
7301    }
7302
7303    /// #152: sozu tells backends apart by address, so a unix backend's
7304    /// identity address may not be shared with another backend.
7305    #[test]
7306    fn a_unix_path_backend_needs_a_unique_address() {
7307        for protocol in ["http", "tcp"] {
7308            let file: FileClusterConfig = toml::from_str(&format!(
7309                "protocol = \"{protocol}\"\nfrontends = []\nbackends = [\n\
7310                 {{ address = \"127.0.0.1:1\", unix_path = \"/run/a.sock\" }},\n\
7311                 {{ address = \"127.0.0.1:1\", unix_path = \"/run/b.sock\" }},\n]\n"
7312            ))
7313            .expect("the cluster must parse");
7314            assert!(
7315                matches!(
7316                    file.to_cluster_config("app", &HashSet::new()),
7317                    Err(ConfigError::InvalidBackendUnixPath { .. })
7318                ),
7319                "a {protocol} cluster must refuse a shared unix backend address"
7320            );
7321        }
7322    }
7323
7324    /// A probe `port` targets a backend IP and a `unix_path` backend has none:
7325    /// the file refuses the pair, whichever protocol, whether the entry is a
7326    /// literal or a name DNS expands, and whether or not a resolver runs.
7327    #[test]
7328    fn a_health_check_port_and_a_unix_path_backend_never_share_a_file_cluster() {
7329        let resolver = |_: &str| -> std::io::Result<Vec<SocketAddr>> {
7330            Ok(vec!["10.0.0.2:8080".parse().unwrap()])
7331        };
7332        for protocol in ["http", "tcp"] {
7333            for address in ["127.0.0.1:1", "ingress:8080"] {
7334                let build = |health_check: &str, resolver: Option<&BackendResolver<'_>>| {
7335                    toml::from_str::<FileClusterConfig>(&format!(
7336                        "protocol = \"{protocol}\"\nfrontends = []\nbackends = [\n\
7337                         {{ address = \"{address}\", unix_path = \"/run/app.sock\" }},\n]\n\
7338                         [health_check]\n{health_check}"
7339                    ))
7340                    .expect("the cluster must parse")
7341                    .to_cluster_config_with("app", &HashSet::new(), resolver)
7342                };
7343                for resolver in [Some(&resolver as &BackendResolver<'_>), None] {
7344                    let error = build("mode = \"TCP\"\nport = 8081\n", resolver)
7345                        .expect_err("a probe port next to a unix backend must be refused");
7346                    assert!(
7347                        matches!(
7348                            &error,
7349                            ConfigError::InvalidHealthCheck { cluster_id, reason }
7350                                if cluster_id == "app" && *reason == HEALTH_CHECK_PORT_UNIX_REFUSED
7351                        ),
7352                        "{protocol} {address}: unexpected {error:?}"
7353                    );
7354                    build("mode = \"TCP\"\n", resolver)
7355                        .expect("a unix backend without a probe port stays valid");
7356                }
7357            }
7358        }
7359    }
7360
7361    /// #152: what `parse_backend_unix_path` accepts and refuses.
7362    #[test]
7363    fn parse_backend_unix_path_accepts_absolute_and_abstract_names_only() {
7364        let path = parse_backend_unix_path("/run/app.sock").expect("an absolute path parses");
7365        assert_eq!(
7366            path.as_pathname(),
7367            Some(std::path::Path::new("/run/app.sock"))
7368        );
7369        #[cfg(target_os = "linux")]
7370        {
7371            use std::os::linux::net::SocketAddrExt;
7372            let named = parse_backend_unix_path("@app").expect("an abstract name parses");
7373            assert_eq!(named.as_abstract_name(), Some(&b"app"[..]));
7374            assert_eq!(named.as_pathname(), None);
7375        }
7376        let too_long = format!("/{}", "a".repeat(200));
7377        for refused in ["", "run/app.sock", "@", "/run/a\0b", too_long.as_str()] {
7378            assert!(
7379                parse_backend_unix_path(refused).is_err(),
7380                "{refused:?} must be refused"
7381            );
7382        }
7383    }
7384
7385    /// #1253: `header_predicates` travel from the TOML file to the
7386    /// `AddHttpFrontend` order, `match` parsed case-insensitively.
7387    #[test]
7388    fn header_predicates_reach_the_add_http_frontend_order() {
7389        let file: FileClusterConfig = toml::from_str(
7390            r#"
7391            protocol = "http"
7392            frontends = [
7393              { address = "0.0.0.0:80", hostname = "api.example.com", header_predicates = [
7394                { name = "X-Canary", match = "EXACT", value = "always" },
7395                { name = "X-Region", match = "regex", value = "eu-.*" },
7396                { name = "X-Beta", match = "present" },
7397              ] },
7398            ]
7399            backends = []
7400            "#,
7401        )
7402        .expect("the cluster must parse");
7403        let ClusterConfig::Http(http) = file
7404            .to_cluster_config("canary", &HashSet::new())
7405            .expect("the cluster must build")
7406        else {
7407            panic!("an http cluster must build as one");
7408        };
7409        let orders = http.generate_requests().expect("the orders must build");
7410        let front = orders
7411            .iter()
7412            .find_map(|order| match order.request_type.as_ref() {
7413                Some(RequestType::AddHttpFrontendWithHeaderPredicates(front)) => Some(front),
7414                _ => None,
7415            })
7416            .expect("the orders must add the frontend");
7417        let predicate = |name: &str, kind: HeaderMatchKind, value: Option<&str>| HeaderPredicate {
7418            name: name.to_owned(),
7419            kind: kind as i32,
7420            value: value.map(str::to_owned),
7421        };
7422        assert_eq!(
7423            front.header_predicates,
7424            vec![
7425                predicate("X-Canary", HeaderMatchKind::Exact, Some("always")),
7426                predicate("X-Region", HeaderMatchKind::Regex, Some("eu-.*")),
7427                predicate("X-Beta", HeaderMatchKind::Present, None),
7428            ]
7429        );
7430    }
7431
7432    /// #1253: a predicate no request could satisfy is refused at config
7433    /// load with its index, never left to silently match nothing.
7434    #[test]
7435    fn unusable_header_predicates_are_refused_at_config_load() {
7436        for entry in [
7437            r#"{ name = "X-Canary", match = "equals", value = "1" }"#,
7438            r#"{ name = "Host", match = "exact", value = "example.com" }"#,
7439            r#"{ name = "Cookie", match = "present" }"#,
7440            r#"{ name = "X-Canary", match = "present", value = "1" }"#,
7441            r#"{ name = "X-Canary", match = "exact" }"#,
7442            r#"{ name = "X Canary", match = "present" }"#,
7443        ] {
7444            let file: FileClusterConfig = toml::from_str(&format!(
7445                r#"
7446                protocol = "http"
7447                frontends = [
7448                  {{ address = "0.0.0.0:80", hostname = "api.example.com", header_predicates = [
7449                    {{ name = "X-Valid", match = "present" }},
7450                    {entry},
7451                  ] }},
7452                ]
7453                backends = []
7454                "#
7455            ))
7456            .expect("the cluster must parse");
7457            match file.to_cluster_config("canary", &HashSet::new()) {
7458                Err(ConfigError::InvalidHeaderPredicate { index: 1, .. }) => {}
7459                other => panic!("{entry}: expected InvalidHeaderPredicate at 1, got {other:?}"),
7460            }
7461        }
7462    }
7463
7464    /// #524: a TCP cluster has no request to read a header or a cookie
7465    /// from, so declaring one is refused rather than silently ignored.
7466    #[test]
7467    fn a_tcp_cluster_refuses_an_affinity_header_or_cookie() {
7468        for key in [
7469            "affinity_header = \"X-Tenant\"",
7470            "affinity_cookie = \"tenant\"",
7471        ] {
7472            let file: FileClusterConfig = toml::from_str(&format!(
7473                "protocol = \"tcp\"\nload_balancing = \"MAGLEV\"\n{key}\nfrontends = []\nbackends = []\n"
7474            ))
7475            .expect("the cluster must parse");
7476            assert!(
7477                matches!(
7478                    file.to_cluster_config("keyed", &HashSet::new()),
7479                    Err(ConfigError::InvalidAffinityKey { .. })
7480                ),
7481                "a TCP cluster must refuse `{key}`"
7482            );
7483        }
7484    }
7485
7486    /// #524: at most one key source, and a name a request can carry.
7487    #[test]
7488    fn validate_affinity_key_accepts_one_token_and_refuses_the_rest() {
7489        assert!(validate_affinity_key(None, None).is_ok());
7490        assert!(validate_affinity_key(Some("X-Tenant"), None).is_ok());
7491        assert!(validate_affinity_key(None, Some("tenant_id")).is_ok());
7492        assert!(validate_affinity_key(Some("X-Tenant"), Some("tenant")).is_err());
7493        for unreachable in ["Host", "host", "HOST", "Cookie", "cookie"] {
7494            assert!(
7495                validate_affinity_key(Some(unreachable), None).is_err(),
7496                "header {unreachable:?} can never be found and must be refused"
7497            );
7498        }
7499        // A cookie merely NAMED like those headers is an ordinary cookie.
7500        assert!(validate_affinity_key(None, Some("host")).is_ok());
7501        for bad in ["", "X Tenant", "X-Tenant:", "tenant\r\n", "t=1"] {
7502            assert!(
7503                validate_affinity_key(Some(bad), None).is_err(),
7504                "header {bad:?} must be refused"
7505            );
7506            assert!(
7507                validate_affinity_key(None, Some(bad)).is_err(),
7508                "cookie {bad:?} must be refused"
7509            );
7510        }
7511    }
7512
7513    /// #524: shard knobs travel from the TOML file to the `AddCluster`
7514    /// order, and an invalid combination is refused at load.
7515    #[test]
7516    fn shuffle_sharding_reaches_the_add_cluster_order_and_is_validated() {
7517        let parse = |extra: &str| -> FileClusterConfig {
7518            toml::from_str(&format!(
7519                "protocol = \"http\"\nfrontends = []\nbackends = []\n{extra}"
7520            ))
7521            .expect("the cluster must parse")
7522        };
7523        let ClusterConfig::Http(http) =
7524            parse("shard_percent = 25\nshard_min_backends = 4\nshard_mode = \"STRICT\"\n")
7525                .to_cluster_config("sharded", &HashSet::new())
7526                .expect("a valid sharded cluster builds")
7527        else {
7528            panic!("an http cluster must build as one");
7529        };
7530        let orders = http.generate_requests().expect("the orders must build");
7531        let Some(RequestType::AddCluster(cluster)) =
7532            orders.first().and_then(|order| order.request_type.as_ref())
7533        else {
7534            panic!("the orders must lead with an AddCluster");
7535        };
7536        assert_eq!(
7537            (
7538                cluster.shard_percent,
7539                cluster.shard_min_backends,
7540                cluster.shard_mode
7541            ),
7542            (Some(25), Some(4), Some(ShardMode::Strict as i32))
7543        );
7544        for bad in [
7545            "shard_percent = 0\n",
7546            "shard_percent = 101\n",
7547            "shard_percent = 25\nshard_min_backends = 1\n",
7548            "shard_min_backends = 8\n",
7549            "shard_mode = \"STRICT\"\n",
7550        ] {
7551            assert!(
7552                matches!(
7553                    parse(bad).to_cluster_config("sharded", &HashSet::new()),
7554                    Err(ConfigError::InvalidShuffleSharding { .. })
7555                ),
7556                "{bad:?} must be refused"
7557            );
7558        }
7559    }
7560
7561    /// #1801: `accepted_statuses` entries parse to inclusive ranges, and
7562    /// everything outside the documented syntax or `100..=599` is refused.
7563    #[test]
7564    fn http_status_range_parses_codes_ranges_classes_and_any() {
7565        let range = |start, end| HttpStatusRange { start, end };
7566        for (spec, expected) in [
7567            ("404", range(404, 404)),
7568            ("200-399", range(200, 399)),
7569            (" 200 - 399 ", range(200, 399)),
7570            ("2xx", range(200, 299)),
7571            ("5XX", range(500, 599)),
7572            ("any", range(100, 599)),
7573            ("ANY", range(100, 599)),
7574            ("100-599", range(100, 599)),
7575        ] {
7576            assert_eq!(parse_http_status_range(spec), Ok(expected), "{spec:?}");
7577        }
7578        for spec in [
7579            "", "abc", "99", "600", "0", "6xx", "0xx", "2x", "2xxx", "399-200", "200-", "-200",
7580            "+200", "200-600", "1-2-3", "2xx-3xx",
7581        ] {
7582            assert!(
7583                parse_http_status_range(spec).is_err(),
7584                "{spec:?} must be refused"
7585            );
7586        }
7587    }
7588
7589    /// #1801: the rendering used by `sozu cluster health-check list` parses
7590    /// back to the same range.
7591    #[test]
7592    fn http_status_range_display_round_trips_through_the_parser() {
7593        for spec in ["404", "200-399", "2xx", "any", "101-102"] {
7594            let range = parse_http_status_range(spec).unwrap();
7595            assert_eq!(range.to_string(), spec);
7596            assert_eq!(parse_http_status_range(&range.to_string()), Ok(range));
7597        }
7598    }
7599
7600    /// #1801: validation of the probe mode, the TCP-mode URI exemption, the
7601    /// range bounds and the `expected_status` / `accepted_statuses`
7602    /// exclusion — the rules shared by the CLI, the TOML loader, the master
7603    /// state and the worker.
7604    #[test]
7605    fn health_check_validation_covers_mode_uri_and_accepted_statuses() {
7606        let http = HealthCheckConfig {
7607            uri: "/health".to_owned(),
7608            interval: 10,
7609            timeout: 5,
7610            healthy_threshold: 3,
7611            unhealthy_threshold: 3,
7612            expected_status: 0,
7613            mode: HealthCheckMode::Http as i32,
7614            accepted_statuses: Vec::new(),
7615            port: None,
7616            headers: Vec::new(),
7617        };
7618        assert_eq!(validate_health_check_config(&http), Ok(()));
7619
7620        // TCP mode ignores the URI, so an empty one is fine; HTTP mode is not.
7621        let tcp_no_uri = HealthCheckConfig {
7622            uri: String::new(),
7623            mode: HealthCheckMode::Tcp as i32,
7624            ..http.clone()
7625        };
7626        assert_eq!(validate_health_check_config(&tcp_no_uri), Ok(()));
7627        let http_no_uri = HealthCheckConfig {
7628            uri: String::new(),
7629            ..http.clone()
7630        };
7631        assert!(validate_health_check_config(&http_no_uri).is_err());
7632        // Control bytes are refused in every mode.
7633        let tcp_crlf = HealthCheckConfig {
7634            uri: "/a\r\nGET /b".to_owned(),
7635            ..tcp_no_uri.clone()
7636        };
7637        assert!(validate_health_check_config(&tcp_crlf).is_err());
7638
7639        // TCP mode judges no status, so status fields beside it are refused.
7640        let tcp_expected = HealthCheckConfig {
7641            expected_status: 200,
7642            ..tcp_no_uri.clone()
7643        };
7644        assert!(validate_health_check_config(&tcp_expected).is_err());
7645        let tcp_accepted = HealthCheckConfig {
7646            accepted_statuses: vec![HttpStatusRange {
7647                start: 100,
7648                end: 599,
7649            }],
7650            ..tcp_no_uri.clone()
7651        };
7652        assert!(validate_health_check_config(&tcp_accepted).is_err());
7653
7654        let unknown_mode = HealthCheckConfig {
7655            mode: 7,
7656            ..http.clone()
7657        };
7658        assert!(validate_health_check_config(&unknown_mode).is_err());
7659
7660        let accepted = HealthCheckConfig {
7661            accepted_statuses: vec![HttpStatusRange {
7662                start: 200,
7663                end: 499,
7664            }],
7665            ..http.clone()
7666        };
7667        assert_eq!(validate_health_check_config(&accepted), Ok(()));
7668        let both = HealthCheckConfig {
7669            expected_status: 200,
7670            ..accepted.clone()
7671        };
7672        assert!(
7673            validate_health_check_config(&both).is_err(),
7674            "expected_status and accepted_statuses must not both be set"
7675        );
7676        for (start, end) in [(99, 200), (200, 600), (300, 200)] {
7677            let bad_range = HealthCheckConfig {
7678                accepted_statuses: vec![HttpStatusRange { start, end }],
7679                ..http.clone()
7680            };
7681            assert!(
7682                validate_health_check_config(&bad_range).is_err(),
7683                "{start}-{end} must be refused"
7684            );
7685        }
7686    }
7687
7688    /// #1801: the TOML block defaults to HTTP mode on `/`, accepts
7689    /// `mode = "TCP"` without a `uri`, carries `accepted_statuses` to the
7690    /// `AddCluster` order, and refuses a bad block at load.
7691    #[test]
7692    fn health_check_toml_mode_and_accepted_statuses_reach_the_add_cluster_order() {
7693        let parse = |block: &str| -> FileClusterConfig {
7694            toml::from_str(&format!(
7695                "protocol = \"http\"\nfrontends = []\nbackends = []\n[health_check]\n{block}"
7696            ))
7697            .expect("the cluster must parse")
7698        };
7699        let health_check = |block: &str| -> HealthCheckConfig {
7700            let ClusterConfig::Http(http) = parse(block)
7701                .to_cluster_config("checked", &HashSet::new())
7702                .expect("a valid health check builds")
7703            else {
7704                panic!("an http cluster must build as one");
7705            };
7706            let orders = http.generate_requests().expect("the orders must build");
7707            let Some(RequestType::AddCluster(cluster)) =
7708                orders.first().and_then(|order| order.request_type.as_ref())
7709            else {
7710                panic!("the orders must lead with an AddCluster");
7711            };
7712            cluster
7713                .health_check
7714                .clone()
7715                .expect("the health check must travel")
7716        };
7717
7718        // A legacy block keeps its meaning: HTTP mode, no list.
7719        let legacy = health_check("uri = \"/health\"\nexpected_status = 204\n");
7720        assert_eq!(legacy.mode, HealthCheckMode::Http as i32);
7721        assert_eq!(
7722            (legacy.uri.as_str(), legacy.expected_status),
7723            ("/health", 204)
7724        );
7725        assert!(legacy.accepted_statuses.is_empty());
7726
7727        let defaulted = health_check("");
7728        assert_eq!(defaulted.mode, HealthCheckMode::Http as i32);
7729        assert_eq!(defaulted.uri, "/");
7730
7731        let tcp = health_check("mode = \"TCP\"\n");
7732        assert_eq!(tcp.mode, HealthCheckMode::Tcp as i32);
7733
7734        let accepted = health_check("uri = \"/livez\"\naccepted_statuses = [\"2xx\", \"404\"]\n");
7735        assert_eq!(
7736            accepted.accepted_statuses,
7737            vec![
7738                HttpStatusRange {
7739                    start: 200,
7740                    end: 299
7741                },
7742                HttpStatusRange {
7743                    start: 404,
7744                    end: 404
7745                },
7746            ]
7747        );
7748
7749        for bad in [
7750            "accepted_statuses = [\"6xx\"]\n",
7751            "expected_status = 200\naccepted_statuses = [\"any\"]\n",
7752            "uri = \"health\"\n",
7753            "mode = \"TCP\"\nexpected_status = 200\n",
7754            "mode = \"TCP\"\naccepted_statuses = [\"any\"]\n",
7755        ] {
7756            assert!(
7757                matches!(
7758                    parse(bad).to_cluster_config("checked", &HashSet::new()),
7759                    Err(ConfigError::InvalidHealthCheck { .. })
7760                ),
7761                "{bad:?} must be refused"
7762            );
7763        }
7764    }
7765
7766    /// The probe `port` is optional; when set it must be a non-zero 16-bit
7767    /// port, in either mode.
7768    #[test]
7769    fn health_check_validation_bounds_the_probe_port() {
7770        for mode in [HealthCheckMode::Http, HealthCheckMode::Tcp] {
7771            let config = |port: Option<u32>| HealthCheckConfig {
7772                uri: "/health".to_owned(),
7773                interval: 10,
7774                timeout: 5,
7775                healthy_threshold: 3,
7776                unhealthy_threshold: 3,
7777                mode: mode as i32,
7778                port,
7779                ..Default::default()
7780            };
7781            for port in [None, Some(1), Some(8080), Some(65535)] {
7782                assert_eq!(
7783                    validate_health_check_config(&config(port)),
7784                    Ok(()),
7785                    "{mode:?} probe port {port:?} must be accepted"
7786                );
7787            }
7788            for port in [0, 65536, u32::MAX] {
7789                assert_eq!(
7790                    validate_health_check_config(&config(Some(port))),
7791                    Err("health check port must lie in 1..=65535"),
7792                    "{mode:?} probe port {port} must be refused"
7793                );
7794            }
7795        }
7796    }
7797
7798    /// The TOML `port` reaches the `AddCluster` order, an absent one stays
7799    /// unset, and an out-of-range one is refused at load.
7800    #[test]
7801    fn health_check_toml_port_reaches_the_add_cluster_order() {
7802        let build = |block: &str| {
7803            toml::from_str::<FileClusterConfig>(&format!(
7804                "protocol = \"tcp\"\nfrontends = []\nbackends = []\n[health_check]\n{block}"
7805            ))
7806            .expect("the cluster must parse")
7807            .to_cluster_config("checked", &HashSet::new())
7808        };
7809        let port = |block: &str| -> Option<u32> {
7810            let Ok(ClusterConfig::Tcp(tcp)) = build(block) else {
7811                panic!("a tcp cluster with a valid health check must build as one");
7812            };
7813            tcp.health_check.expect("the health check must travel").port
7814        };
7815
7816        assert_eq!(port("mode = \"TCP\"\n"), None);
7817        assert_eq!(port("mode = \"TCP\"\nport = 8081\n"), Some(8081));
7818        assert_eq!(port("uri = \"/health\"\nport = 65535\n"), Some(65535));
7819        for bad in ["port = 0\n", "port = 65536\n"] {
7820            assert!(
7821                matches!(build(bad), Err(ConfigError::InvalidHealthCheck { .. })),
7822                "{bad:?} must be refused"
7823            );
7824        }
7825    }
7826
7827    /// A valid HTTP health check carrying `headers`, for the header tests.
7828    fn health_check_with_headers(headers: &[(&str, &str)]) -> HealthCheckConfig {
7829        HealthCheckConfig {
7830            uri: "/".to_owned(),
7831            interval: 10,
7832            timeout: 5,
7833            healthy_threshold: 3,
7834            unhealthy_threshold: 3,
7835            expected_status: 0,
7836            mode: HealthCheckMode::Http as i32,
7837            accepted_statuses: Vec::new(),
7838            port: None,
7839            headers: headers
7840                .iter()
7841                .map(|(name, _)| HealthCheckHeader {
7842                    name: (*name).to_owned(),
7843                    fingerprint: String::new(),
7844                })
7845                .collect(),
7846        }
7847    }
7848
7849    /// Names, then values, through both validators a protected order meets.
7850    fn check_headers(headers: &[(&str, &str)]) -> Result<(), &'static str> {
7851        let config = health_check_with_headers(headers);
7852        validate_health_check_config(&config)?;
7853        let values: Vec<HealthCheckHeaderValue> = headers
7854            .iter()
7855            .map(|(name, value)| HealthCheckHeaderValue {
7856                name: (*name).to_owned(),
7857                value: value.as_bytes().to_vec(),
7858            })
7859            .collect();
7860        validate_health_check_header_values(&config, &values)
7861    }
7862
7863    /// RFC 9113 §8.2.1: a field value must not begin or end with SP or HTAB.
7864    /// Direct protobuf and TOML values keep their exact bytes, so a value
7865    /// that would need trimming is refused rather than silently changed; an
7866    /// empty value and inner whitespace stay valid.
7867    #[test]
7868    fn health_check_header_value_refuses_leading_and_trailing_ows() {
7869        for value in [" v", "v ", "\tv", "v\t", " ", "\t", " v ", "a b \t"] {
7870            assert_eq!(
7871                check_headers(&[("x-a", value)]),
7872                Err("health check header value must not begin or end with a space or a tab"),
7873                "{value:?} must be refused"
7874            );
7875        }
7876        for value in ["", "v", "a b", "a\tb", "é ü", "\u{a0}v"] {
7877            assert_eq!(
7878                check_headers(&[("x-a", value)]),
7879                Ok(()),
7880                "{value:?} must be accepted"
7881            );
7882        }
7883    }
7884
7885    /// Every refusal class of the custom health-check headers, and the edge
7886    /// each bound accepts.
7887    #[test]
7888    fn health_check_header_validation_covers_every_class() {
7889        let refused = |headers: &[(&str, &str)], reason: &str| {
7890            assert_eq!(
7891                check_headers(headers),
7892                Err(reason),
7893                "{headers:?} must be refused"
7894            );
7895        };
7896
7897        // Accepted shapes: ordered, mixed case, empty value, tab inside,
7898        // obs-text (valid UTF-8 above 0x7F).
7899        assert_eq!(
7900            check_headers(&[
7901                ("Authorization", "Bearer t"),
7902                ("X-Empty", ""),
7903                ("x-tab", "a\tb"),
7904                ("x-utf8", "caf\u{e9} \u{1F600}"),
7905            ]),
7906            Ok(())
7907        );
7908
7909        // TCP mode sends no request.
7910        let mut tcp = health_check_with_headers(&[("x-a", "1")]);
7911        tcp.mode = HealthCheckMode::Tcp as i32;
7912        assert_eq!(
7913            validate_health_check_config(&tcp),
7914            Err("health check headers apply to HTTP mode only: leave them unset with mode TCP")
7915        );
7916
7917        // Count: 32 pass, 33 do not.
7918        let names: Vec<String> = (0..33).map(|i| format!("x-{i}")).collect();
7919        let many = |count: usize| -> Vec<(&str, &str)> {
7920            names[..count].iter().map(|n| (n.as_str(), "v")).collect()
7921        };
7922        assert_eq!(check_headers(&many(32)), Ok(()));
7923        refused(&many(33), "health check carries more than 32 headers");
7924
7925        // Reserved names, whatever the case, and pseudo-header names.
7926        for name in [
7927            ":authority",
7928            ":x",
7929            "Host",
7930            "HOST",
7931            "connection",
7932            "Keep-Alive",
7933            "proxy-connection",
7934            "Transfer-Encoding",
7935            "content-length",
7936            "TE",
7937            "trailer",
7938            "upgrade",
7939            "Expect",
7940        ] {
7941            refused(
7942                &[(name, "v")],
7943                "health check header name is reserved for the probe or its framing",
7944            );
7945        }
7946
7947        // Names that are not an RFC 9110 token.
7948        for name in [
7949            "",
7950            " x",
7951            "x ",
7952            "a b",
7953            "a:b",
7954            "caf\u{e9}",
7955            "a\r",
7956            "a\"b",
7957            "a(b",
7958        ] {
7959            refused(
7960                &[(name, "v")],
7961                "health check header name must be a non-empty RFC 9110 token",
7962            );
7963        }
7964
7965        // Control bytes: CR, LF, NUL, other C0 except HTAB, DEL.
7966        for value in [
7967            "a\rb", "a\nb", "a\0b", "a\x01b", "a\x08b", "a\x0bb", "a\x1fb", "a\x7fb",
7968        ] {
7969            refused(
7970                &[("x-a", value)],
7971                "health check header value must not contain control bytes",
7972            );
7973        }
7974
7975        // Duplicate names compare case-insensitively.
7976        refused(
7977            &[("X-Token", "1"), ("x-token", "2")],
7978            "health check header names must be unique, compared case-insensitively",
7979        );
7980
7981        // The 8192-byte wire sum is name + value + 4 per header.
7982        let value_fits = "v".repeat(8192 - 1 - 4);
7983        assert_eq!(check_headers(&[("x", &value_fits)]), Ok(()));
7984        let value_over = "v".repeat(8192 - 1 - 4 + 1);
7985        refused(
7986            &[("x", &value_over)],
7987            "health check headers exceed 8192 bytes (name + value + 4 per header)",
7988        );
7989
7990        // The HTTP/2 block bound counts the URI as well: the URI alone, or the
7991        // headers alone, pass; together they may exceed one HEADERS frame.
7992        let mut long_uri = health_check_with_headers(&[]);
7993        long_uri.uri = format!("/{}", "a".repeat(9000));
7994        assert_eq!(validate_health_check_config(&long_uri), Ok(()));
7995        long_uri.headers = vec![HealthCheckHeader {
7996            name: "x".to_owned(),
7997            fingerprint: String::new(),
7998        }];
7999        assert_eq!(validate_health_check_config(&long_uri), Ok(()));
8000        let carrier = HealthCheckHeaderValue {
8001            name: "x".to_owned(),
8002            value: "v".repeat(8000).into_bytes(),
8003        };
8004        assert_eq!(
8005            validate_health_check_header_values(&long_uri, &[carrier]),
8006            Err("health check URI and headers may exceed the 16384-byte HTTP/2 probe block")
8007        );
8008
8009        // A value that does not follow its header's name, count or order is
8010        // refused, and so is one that is not UTF-8, without echoing either.
8011        let one = health_check_with_headers(&[("x-a", ""), ("x-b", "")]);
8012        let carrier = |name: &str, value: &[u8]| HealthCheckHeaderValue {
8013            name: name.to_owned(),
8014            value: value.to_vec(),
8015        };
8016        assert_eq!(
8017            validate_health_check_header_values(&one, &[carrier("x-a", b"1")]),
8018            Err("health check carries a header value count different from its header count")
8019        );
8020        assert_eq!(
8021            validate_health_check_header_values(
8022                &one,
8023                &[carrier("x-b", b"1"), carrier("x-a", b"2")]
8024            ),
8025            Err("health check header values must follow the order and names of the headers")
8026        );
8027        assert_eq!(
8028            validate_health_check_header_values(
8029                &one,
8030                &[carrier("x-a", b"1"), carrier("x-b", &[0xff])]
8031            ),
8032            Err("health check header value must be valid UTF-8")
8033        );
8034        // A fingerprint that is not one is no metadata.
8035        let mut spoofed = health_check_with_headers(&[("x-a", "")]);
8036        spoofed.headers[0].fingerprint = "not a fingerprint".to_owned();
8037        assert_eq!(
8038            validate_health_check_config(&spoofed),
8039            Err("health check header fingerprint is malformed")
8040        );
8041    }
8042
8043    /// An older state record or peer carries no `headers`: it loads as an empty
8044    /// list. A list survives JSON (state file) and protobuf (command socket)
8045    /// round trips with its order and exact bytes.
8046    #[test]
8047    fn health_check_headers_default_empty_and_round_trip_in_order() {
8048        use prost::Message;
8049
8050        let config = health_check_with_headers(&[
8051            ("Z-Last-Alphabetically", "1"),
8052            ("authorization", "Bearer \u{e9}\tx"),
8053            ("A-First-Alphabetically", ""),
8054        ]);
8055
8056        // JSON without the key.
8057        let mut value = serde_json::to_value(&config).expect("serialize");
8058        value
8059            .as_object_mut()
8060            .expect("an object")
8061            .remove("headers")
8062            .expect("the key is serialized");
8063        let legacy: HealthCheckConfig = serde_json::from_value(value).expect("legacy record loads");
8064        assert!(legacy.headers.is_empty());
8065
8066        // JSON round trip.
8067        let json = serde_json::to_string(&config).expect("serialize");
8068        let back: HealthCheckConfig = serde_json::from_str(&json).expect("deserialize");
8069        assert_eq!(back, config);
8070        assert_eq!(
8071            back.headers
8072                .iter()
8073                .map(|h| h.name.as_str())
8074                .collect::<Vec<_>>(),
8075            [
8076                "Z-Last-Alphabetically",
8077                "authorization",
8078                "A-First-Alphabetically"
8079            ]
8080        );
8081
8082        // Protobuf: a message without field 10 decodes to an empty list, and
8083        // the list round-trips in order.
8084        let headerless = HealthCheckConfig {
8085            headers: Vec::new(),
8086            ..config.clone()
8087        };
8088        let decoded =
8089            HealthCheckConfig::decode(headerless.encode_to_vec().as_slice()).expect("decodes");
8090        assert!(decoded.headers.is_empty());
8091        let decoded =
8092            HealthCheckConfig::decode(config.encode_to_vec().as_slice()).expect("decodes");
8093        assert_eq!(decoded, config);
8094    }
8095
8096    /// `[[clusters.<id>.health_check.headers]]` keeps array order down to the
8097    /// metadata and the `value_file` references, is held to the name
8098    /// validator, and is refused where it would become a bare `AddCluster`
8099    /// name with no value.
8100    ///
8101    /// Contract change: this test used to give each header a literal `value`
8102    /// and read the clear values back from `to_protected_parts`, whose exact
8103    /// bytes and OWS rules it checked here. A configuration now holds no value,
8104    /// only a `value_file` reference that is stored unread, so the order, the
8105    /// empty fingerprints, the bare-conversion refusal, the name and mode
8106    /// refusals and the unknown-key refusal are asserted on `to_parts`; the
8107    /// byte-exact value rules stay covered by `check_headers` and move to the
8108    /// moment the file is read.
8109    #[test]
8110    fn health_check_toml_headers_split_into_metadata_and_ordered_references() {
8111        let parse = |block: &str| {
8112            toml::from_str::<FileHealthCheckConfig>(&format!("uri = \"/health\"\n{block}"))
8113                .expect("the table parses")
8114        };
8115
8116        let file = parse(
8117            "[[headers]]\nname = \"Z-Second\"\nvalue_file = \"/missing/z\"\n\
8118             [[headers]]\nname = \"Authorization\"\nvalue_file = \"rel/a\"\n\
8119             [[headers]]\nname = \"X-Empty\"\nvalue_file = \"/missing/e\"\n",
8120        );
8121        let (config, files) = file.to_parts().expect("a valid list builds");
8122        assert_eq!(
8123            config
8124                .headers
8125                .iter()
8126                .map(|h| (h.name.as_str(), h.fingerprint.as_str()))
8127                .collect::<Vec<_>>(),
8128            [("Z-Second", ""), ("Authorization", ""), ("X-Empty", "")],
8129            "metadata names the headers and carries no value or fingerprint"
8130        );
8131        assert_eq!(
8132            files
8133                .iter()
8134                .map(|f| (f.name.as_str(), f.value_file.as_str()))
8135                .collect::<Vec<_>>(),
8136            [
8137                ("Z-Second", "/missing/z"),
8138                ("Authorization", "rel/a"),
8139                ("X-Empty", "/missing/e")
8140            ]
8141        );
8142        // The bare conversion would leave names no probe could fill in.
8143        assert_eq!(
8144            file.to_proto(),
8145            Err(
8146                "health check headers from the configuration file are not available yet: their values need the protected channel"
8147            )
8148        );
8149
8150        // No `headers` key: the bare conversion still works.
8151        let bare = parse("");
8152        assert!(bare.to_proto().expect("builds").headers.is_empty());
8153        assert!(bare.to_parts().expect("builds").1.is_empty());
8154
8155        // Refusals: TCP mode, reserved name, duplicate name.
8156        for bad in [
8157            "mode = \"TCP\"\n[[headers]]\nname = \"x\"\nvalue_file = \"/v\"\n",
8158            "[[headers]]\nname = \"Host\"\nvalue_file = \"/v\"\n",
8159            "[[headers]]\nname = \"x\"\nvalue_file = \"/v\"\n\
8160             [[headers]]\nname = \"X\"\nvalue_file = \"/w\"\n",
8161        ] {
8162            assert!(parse(bad).to_parts().is_err(), "{bad:?} must be refused");
8163        }
8164        // An unknown table key is a parse error.
8165        assert!(
8166            toml::from_str::<FileHealthCheckConfig>(
8167                "uri = \"/\"\n[[headers]]\nname = \"x\"\nvalue_file = \"/v\"\nextra = 1\n"
8168            )
8169            .is_err()
8170        );
8171    }
8172
8173    /// Marker planted in every value the protected-header tests must never
8174    /// see echoed back, and in the reference path that Debug must redact.
8175    const HEADER_SENTINEL: &str = "SENTINEL-HEADER-VALUE-7f3a91";
8176    const REF_SENTINEL: &str = "sentinel-ref-dir-9c1e42";
8177
8178    /// A file cluster of `protocol` whose health check carries `headers`
8179    /// as `(name, value_file)` pairs, in order.
8180    fn value_file_cluster(protocol: &str, headers: &[(&str, &str)]) -> FileClusterConfig {
8181        let tables: String = headers
8182            .iter()
8183            .map(|(name, path)| {
8184                format!("[[health_check.headers]]\nname = \"{name}\"\nvalue_file = \"{path}\"\n")
8185            })
8186            .collect();
8187        toml::from_str(&format!(
8188            "protocol = \"{protocol}\"\nfrontends = []\nbackends = []\n\
8189             [health_check]\nuri = \"/health\"\n{tables}"
8190        ))
8191        .expect("a value_file reference parses")
8192    }
8193
8194    /// The JSON view of a built cluster, for asserting on serialized
8195    /// field names and order.
8196    fn cluster_json(cluster: &ClusterConfig) -> serde_json::Value {
8197        match cluster {
8198            ClusterConfig::Http(http) => serde_json::to_value(http),
8199            ClusterConfig::Tcp(tcp) => serde_json::to_value(tcp),
8200        }
8201        .expect("a cluster serializes")
8202    }
8203
8204    /// `value_file` is a reference: it is kept unchanged and in order, never
8205    /// opened (the paths below cannot exist), and reaches both cluster kinds
8206    /// next to the name-only metadata, through a `Config` serde round trip.
8207    #[test]
8208    fn health_check_value_file_is_kept_unopened_in_order_for_http_and_tcp() {
8209        let missing_a = format!("/{REF_SENTINEL}/missing/a.secret");
8210        let missing_b = format!("relative/../{REF_SENTINEL}/b.secret");
8211        let headers = [
8212            ("Z-Second", missing_a.as_str()),
8213            ("Authorization", &missing_b),
8214        ];
8215
8216        for protocol in ["http", "tcp"] {
8217            let file = value_file_cluster(protocol, &headers);
8218            let cluster = file
8219                .to_cluster_config("checked", &HashSet::new())
8220                .expect("a reference to a missing file builds: nothing opens it");
8221            let json = cluster_json(&cluster);
8222
8223            let metadata: Vec<(&str, &str)> = json["health_check"]["headers"]
8224                .as_array()
8225                .expect("the metadata lists the headers")
8226                .iter()
8227                .map(|h| {
8228                    (
8229                        h["name"].as_str().expect("a name"),
8230                        h["fingerprint"].as_str().expect("a fingerprint"),
8231                    )
8232                })
8233                .collect();
8234            assert_eq!(
8235                metadata,
8236                [("Z-Second", ""), ("Authorization", "")],
8237                "{protocol}: ordered names, empty fingerprints"
8238            );
8239            assert_eq!(
8240                json["health_check_header_files"],
8241                serde_json::json!([
8242                    { "name": "Z-Second", "value_file": missing_a },
8243                    { "name": "Authorization", "value_file": missing_b },
8244                ]),
8245                "{protocol}: references are stored unchanged and in order"
8246            );
8247
8248            let mut config = Config::default();
8249            config
8250                .clusters
8251                .insert("checked".to_owned(), cluster.clone());
8252            let text = serde_json::to_string(&config).expect("a Config serializes");
8253            let back: Config = serde_json::from_str(&text).expect("a Config deserializes");
8254            assert_eq!(
8255                back.clusters.get("checked"),
8256                Some(&cluster),
8257                "{protocol}: references survive Config serde"
8258            );
8259        }
8260
8261        // A cluster without headers carries no reference.
8262        let bare: FileClusterConfig = toml::from_str(
8263            "protocol = \"http\"\nfrontends = []\nbackends = []\n[health_check]\nuri = \"/\"\n",
8264        )
8265        .expect("parses");
8266        let built = bare
8267            .to_cluster_config("bare", &HashSet::new())
8268            .expect("builds");
8269        assert_eq!(
8270            cluster_json(&built)["health_check_header_files"],
8271            serde_json::json!([])
8272        );
8273    }
8274
8275    /// The old `value = "<secret>"` key and every other shape but a
8276    /// `value_file` reference are refused, and neither the parse diagnostic
8277    /// nor Debug echoes a value or a reference path.
8278    #[test]
8279    fn health_check_header_refuses_a_literal_or_env_value_and_never_echoes() {
8280        let parse = |table: &str| {
8281            toml::from_str::<FileHealthCheckConfig>(&format!("uri = \"/\"\n[[headers]]\n{table}\n"))
8282        };
8283
8284        for bad in [
8285            format!("name = \"x\"\nvalue = \"{HEADER_SENTINEL}\""),
8286            format!("name = \"x\"\nvalue_env = \"{HEADER_SENTINEL}\""),
8287            "name = \"x\"".to_owned(),
8288            "value_file = \"/p\"".to_owned(),
8289            format!("name = \"x\"\nvalue_file = \"/p\"\nvalue = \"{HEADER_SENTINEL}\""),
8290            format!("name = \"x\"\nvalue_file = \"/p\"\nvalue_env = \"{HEADER_SENTINEL}\""),
8291            format!("name = \"x\"\nvalue_file = \"/p\"\nextra = \"{HEADER_SENTINEL}\""),
8292            format!("name = \"x\"\nvalue_file = 12\nextra = \"{HEADER_SENTINEL}\""),
8293        ] {
8294            let error = parse(&bad).expect_err(&format!("{bad:?} must be refused"));
8295            let diagnostic = toml_error_diagnostic("/etc/sozu.toml", &error);
8296            assert!(
8297                !diagnostic.contains(HEADER_SENTINEL),
8298                "the diagnostic must not echo a value: {diagnostic}"
8299            );
8300        }
8301
8302        let accepted = parse(&format!("name = \"x\"\nvalue_file = \"/{REF_SENTINEL}/v\""))
8303            .expect("a name and a value_file parse");
8304        assert_eq!(accepted.headers.len(), 1);
8305        let debug = format!("{accepted:?}");
8306        assert!(
8307            !debug.contains(REF_SENTINEL),
8308            "Debug must redact the reference path: {debug}"
8309        );
8310    }
8311
8312    /// Until the protected activation exists, no `WorkerRequest` may leave a
8313    /// cluster whose header values live in files: the refusal is static, comes
8314    /// before any order is returned, and names no reference. A cluster without
8315    /// headers keeps generating its orders.
8316    #[test]
8317    fn health_check_value_file_cluster_generates_no_worker_request() {
8318        for protocol in ["http", "tcp"] {
8319            let path = format!("/{REF_SENTINEL}/v");
8320            let file = value_file_cluster(protocol, &[("x-token", &path)]);
8321            let cluster = file
8322                .to_cluster_config("guarded", &HashSet::new())
8323                .expect("builds");
8324
8325            let direct = cluster
8326                .generate_requests()
8327                .expect_err("no order may carry a name without its value");
8328            assert!(
8329                matches!(direct, ConfigError::InvalidHealthCheck { .. }),
8330                "{protocol}: {direct:?}"
8331            );
8332
8333            let mut config = Config::default();
8334            config.clusters.insert("guarded".to_owned(), cluster);
8335            let error = config
8336                .generate_config_messages()
8337                .expect_err("the whole generation is refused before any order is returned");
8338            let shown = format!("{error} {error:?}");
8339            assert!(
8340                matches!(error, ConfigError::InvalidHealthCheck { .. })
8341                    && shown.contains("guarded")
8342                    && !shown.contains(REF_SENTINEL),
8343                "{protocol}: {shown}"
8344            );
8345        }
8346
8347        let headerless: FileClusterConfig = toml::from_str(
8348            "protocol = \"http\"\nfrontends = []\nbackends = []\n[health_check]\nuri = \"/\"\n",
8349        )
8350        .expect("parses");
8351        let cluster = headerless
8352            .to_cluster_config("plain", &HashSet::new())
8353            .expect("builds");
8354        assert!(cluster.generate_requests().is_ok());
8355    }
8356
8357    #[test]
8358    fn multiple_listeners_generate_correct_worker_requests() {
8359        let toml_content = r#"
8360            command_socket = "/tmp/sozu_test.sock"
8361            worker_count = 1
8362            activate_listeners = true
8363
8364            [[listeners]]
8365            protocol = "http"
8366            address = "172.16.20.1:80"
8367            expect_proxy = true
8368
8369            [[listeners]]
8370            protocol = "http"
8371            address = "10.22.0.1:80"
8372            expect_proxy = false
8373        "#;
8374
8375        let file_config: FileConfig =
8376            toml::from_str(toml_content).expect("Could not parse TOML config");
8377
8378        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8379            .into_config()
8380            .expect("Could not build config");
8381
8382        let messages = config
8383            .generate_config_messages()
8384            .expect("Could not generate config messages");
8385
8386        let add_listener_count = messages
8387            .iter()
8388            .filter(|m| {
8389                matches!(
8390                    m.content.request_type,
8391                    Some(RequestType::AddHttpListener(_))
8392                )
8393            })
8394            .count();
8395
8396        let activate_listener_count = messages
8397            .iter()
8398            .filter(|m| {
8399                matches!(
8400                    m.content.request_type,
8401                    Some(RequestType::ActivateListener(ActivateListener {
8402                        proxy,
8403                        ..
8404                    })) if proxy == ListenerType::Http as i32
8405                )
8406            })
8407            .count();
8408
8409        assert_eq!(add_listener_count, 2);
8410        assert_eq!(activate_listener_count, 2);
8411    }
8412
8413    #[test]
8414    fn documented_udp_dns_example_loads_and_emits_udp_requests() {
8415        // The DNS example from doc/configure.md ("#### UDP clusters"): a
8416        // `protocol = "udp"` listener + a `protocol = "tcp"` cluster whose
8417        // frontend points at that UDP listener address, with datagram knobs
8418        // under `[clusters.dns.udp]`. This must load without a
8419        // `WrongFrontendProtocol` error and emit an `AddUdpListener` and an
8420        // `AddUdpFrontend` (not `AddTcpFrontend`).
8421        let toml_content = r#"
8422            command_socket = "/tmp/sozu_test.sock"
8423            worker_count = 1
8424            activate_listeners = true
8425
8426            [[listeners]]
8427            protocol = "udp"
8428            address  = "0.0.0.0:53"
8429
8430            [clusters.dns]
8431            protocol       = "tcp"
8432            load_balancing = "HRW"
8433            frontends = [
8434              { address = "0.0.0.0:53" }
8435            ]
8436            backends = [
8437              { address = "10.0.0.10:53" },
8438              { address = "10.0.0.11:53" }
8439            ]
8440
8441            [clusters.dns.udp]
8442            affinity_key        = "SOURCE_IP"
8443            responses           = 1
8444            requests            = 0
8445            send_proxy_protocol = true
8446
8447            [clusters.dns.udp.health]
8448            mode      = "TCP_PROBE"
8449            tcp_port  = 53
8450            rise      = 2
8451            fall      = 3
8452            fail_open = true
8453        "#;
8454
8455        let file_config: FileConfig =
8456            toml::from_str(toml_content).expect("Could not parse documented DNS TOML");
8457
8458        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8459            .into_config()
8460            .expect("documented UDP DNS example must load without WrongFrontendProtocol");
8461
8462        // The UDP listener was registered.
8463        assert_eq!(
8464            config.udp_listeners.len(),
8465            1,
8466            "the protocol=\"udp\" listener must be built"
8467        );
8468
8469        let messages = config
8470            .generate_config_messages()
8471            .expect("Could not generate config messages");
8472
8473        let add_udp_listener_count = messages
8474            .iter()
8475            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpListener(_))))
8476            .count();
8477        assert_eq!(
8478            add_udp_listener_count, 1,
8479            "must emit exactly one AddUdpListener"
8480        );
8481
8482        let add_udp_frontend_count = messages
8483            .iter()
8484            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpFrontend(_))))
8485            .count();
8486        assert_eq!(
8487            add_udp_frontend_count, 1,
8488            "the cluster frontend on the UDP listener must emit AddUdpFrontend"
8489        );
8490
8491        // It must NOT have been emitted as a TCP frontend.
8492        let add_tcp_frontend_count = messages
8493            .iter()
8494            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddTcpFrontend(_))))
8495            .count();
8496        assert_eq!(
8497            add_tcp_frontend_count, 0,
8498            "a UDP-listener-addressed frontend must not be emitted as AddTcpFrontend"
8499        );
8500
8501        // The AddUdpFrontend carries the cluster id and address.
8502        let udp_frontend = messages
8503            .iter()
8504            .find_map(|m| match &m.content.request_type {
8505                Some(RequestType::AddUdpFrontend(f)) => Some(f),
8506                _ => None,
8507            })
8508            .expect("AddUdpFrontend must be present");
8509        assert_eq!(udp_frontend.cluster_id, "dns");
8510        assert_eq!(
8511            SocketAddr::from(udp_frontend.address),
8512            "0.0.0.0:53".parse().unwrap()
8513        );
8514
8515        // The UDP cluster knobs from [clusters.dns.udp] survive onto the
8516        // AddCluster request.
8517        let cluster = messages
8518            .iter()
8519            .find_map(|m| match &m.content.request_type {
8520                Some(RequestType::AddCluster(c)) if c.cluster_id == "dns" => Some(c),
8521                _ => None,
8522            })
8523            .expect("AddCluster for 'dns' must be present");
8524        let udp = cluster
8525            .udp
8526            .as_ref()
8527            .expect("[clusters.dns.udp] block must carry onto the cluster");
8528        assert_eq!(udp.responses, Some(1));
8529    }
8530
8531    /// The `interface` key round-trips from the TOML file into every
8532    /// listener config and its activation, and a listener that omits it keeps
8533    /// `interface = None` — today's behaviour. Two listeners may share an
8534    /// address on different interfaces; the same (address, interface) twice is
8535    /// still a duplicate.
8536    #[test]
8537    fn listener_interface_round_trips_from_the_config_file() {
8538        let toml_content = r#"
8539            command_socket = "/tmp/sozu_test.sock"
8540            worker_count = 1
8541            activate_listeners = true
8542
8543            [[listeners]]
8544            protocol = "http"
8545            address = "0.0.0.0:8080"
8546            interface = "wg0"
8547
8548            [[listeners]]
8549            protocol = "http"
8550            address = "0.0.0.0:8080"
8551            interface = "lo"
8552
8553            [[listeners]]
8554            protocol = "http"
8555            address = "0.0.0.0:8081"
8556
8557            [[listeners]]
8558            protocol = "https"
8559            address = "0.0.0.0:8443"
8560            interface = "wg0"
8561
8562            [[listeners]]
8563            protocol = "tcp"
8564            address = "0.0.0.0:9000"
8565            interface = "wg0"
8566
8567            [[listeners]]
8568            protocol = "udp"
8569            address = "0.0.0.0:5353"
8570            interface = "wg0"
8571        "#;
8572
8573        let file_config: FileConfig =
8574            toml::from_str(toml_content).expect("the interface key must parse");
8575        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8576            .into_config()
8577            .expect("listeners on one address with different interfaces must build");
8578
8579        let http: Vec<(String, Option<&str>)> = config
8580            .http_listeners
8581            .iter()
8582            .map(|l| {
8583                (
8584                    SocketAddr::from(l.address).to_string(),
8585                    l.interface.as_deref(),
8586                )
8587            })
8588            .collect();
8589        assert_eq!(
8590            http,
8591            vec![
8592                ("0.0.0.0:8080".to_owned(), Some("wg0")),
8593                ("0.0.0.0:8080".to_owned(), Some("lo")),
8594                ("0.0.0.0:8081".to_owned(), None),
8595            ]
8596        );
8597        assert_eq!(config.https_listeners[0].interface.as_deref(), Some("wg0"));
8598        assert_eq!(config.tcp_listeners[0].interface.as_deref(), Some("wg0"));
8599        assert_eq!(config.udp_listeners[0].interface.as_deref(), Some("wg0"));
8600
8601        let activations: Vec<Option<String>> = config
8602            .generate_config_messages()
8603            .expect("generate the config messages")
8604            .into_iter()
8605            .filter_map(|request| match request.content.request_type {
8606                Some(RequestType::ActivateListener(activate)) => Some(activate.interface),
8607                _ => None,
8608            })
8609            .collect();
8610        assert_eq!(
8611            activations,
8612            vec![
8613                Some("wg0".to_owned()),
8614                Some("lo".to_owned()),
8615                None,
8616                Some("wg0".to_owned()),
8617                Some("wg0".to_owned()),
8618                Some("wg0".to_owned()),
8619            ]
8620        );
8621
8622        let duplicate = r#"
8623            command_socket = "/tmp/sozu_test.sock"
8624            worker_count = 1
8625
8626            [[listeners]]
8627            protocol = "http"
8628            address = "0.0.0.0:8080"
8629            interface = "wg0"
8630
8631            [[listeners]]
8632            protocol = "http"
8633            address = "0.0.0.0:8080"
8634            interface = "wg0"
8635        "#;
8636        let file_config: FileConfig = toml::from_str(duplicate).expect("parse");
8637        assert!(
8638            ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8639                .into_config()
8640                .is_err(),
8641            "the same (address, interface) twice must be refused"
8642        );
8643    }
8644
8645    /// An interface name the kernel would refuse is reported at config load,
8646    /// naming the listener and the interface.
8647    #[test]
8648    fn invalid_listener_interface_rejected_at_config_load() {
8649        let toml_content = r#"
8650            command_socket = "/tmp/sozu_test.sock"
8651            worker_count = 1
8652
8653            [[listeners]]
8654            protocol = "http"
8655            address = "0.0.0.0:8080"
8656            interface = "wg/0"
8657        "#;
8658        let file_config: FileConfig = toml::from_str(toml_content).expect("parse");
8659        let error = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8660            .into_config()
8661            .expect_err("an invalid interface name must be refused");
8662        let message = error.to_string();
8663        assert!(message.contains("0.0.0.0:8080"), "{message}");
8664        assert!(message.contains("wg/0"), "{message}");
8665    }
8666
8667    #[test]
8668    fn duplicate_listener_address_rejected() {
8669        let toml_content = r#"
8670            command_socket = "/tmp/sozu_test.sock"
8671            worker_count = 1
8672
8673            [[listeners]]
8674            protocol = "http"
8675            address = "0.0.0.0:80"
8676
8677            [[listeners]]
8678            protocol = "http"
8679            address = "0.0.0.0:80"
8680        "#;
8681
8682        let file_config: FileConfig =
8683            toml::from_str(toml_content).expect("Could not parse TOML config");
8684
8685        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8686
8687        assert!(
8688            result.is_err(),
8689            "Should reject duplicate listener addresses"
8690        );
8691    }
8692
8693    #[test]
8694    fn buffer_size_below_h2_minimum_rejected() {
8695        // Default ALPN ["h2", "http/1.1"] + buffer_size = 8192 must error.
8696        let toml_content = r#"
8697            command_socket = "/tmp/sozu_test.sock"
8698            worker_count = 1
8699            buffer_size = 8192
8700
8701            [[listeners]]
8702            protocol = "https"
8703            address = "127.0.0.1:8443"
8704        "#;
8705        let file_config: FileConfig =
8706            toml::from_str(toml_content).expect("Could not parse TOML config");
8707        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8708        match result {
8709            Err(ConfigError::BufferSizeTooSmallForH2 {
8710                buffer_size: 8192,
8711                minimum: 16_393,
8712                listeners: 1,
8713            }) => {}
8714            other => panic!("expected BufferSizeTooSmallForH2, got {other:?}"),
8715        }
8716    }
8717
8718    #[test]
8719    fn buffer_size_below_h2_minimum_accepted_when_no_h2_listener() {
8720        // Drop "h2" from ALPN — buffer_size = 8192 is now valid.
8721        let toml_content = r#"
8722            command_socket = "/tmp/sozu_test.sock"
8723            worker_count = 1
8724            buffer_size = 8192
8725
8726            [[listeners]]
8727            protocol = "https"
8728            address = "127.0.0.1:8443"
8729            alpn_protocols = ["http/1.1"]
8730        "#;
8731        let file_config: FileConfig =
8732            toml::from_str(toml_content).expect("Could not parse TOML config");
8733        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8734        assert!(
8735            result.is_ok(),
8736            "non-H2 HTTPS listener with sub-16393 buffer should be accepted: {result:?}"
8737        );
8738    }
8739
8740    /// An HTTP listener with `h2c_prior_knowledge` runs the H2 mux, so it is
8741    /// held to the H2 minimum buffer size like an HTTPS listener advertising
8742    /// `h2` (sozu-proxy/sozu#1911); without the flag the same buffer is fine.
8743    #[test]
8744    fn buffer_size_below_h2_minimum_rejected_for_an_h2c_http_listener() {
8745        let parse = |h2c: bool| {
8746            let toml_content = format!(
8747                r#"
8748            command_socket = "/tmp/sozu_test.sock"
8749            worker_count = 1
8750            buffer_size = 8192
8751
8752            [[listeners]]
8753            protocol = "http"
8754            address = "127.0.0.1:8080"
8755            h2c_prior_knowledge = {h2c}
8756        "#
8757            );
8758            let file_config: FileConfig =
8759                toml::from_str(&toml_content).expect("Could not parse TOML config");
8760            ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config()
8761        };
8762        match parse(true) {
8763            Err(ConfigError::BufferSizeTooSmallForH2 {
8764                buffer_size: 8192,
8765                minimum: 16_393,
8766                listeners: 1,
8767            }) => {}
8768            other => panic!("expected BufferSizeTooSmallForH2, got {other:?}"),
8769        }
8770        let accepted = parse(false);
8771        assert!(
8772            accepted.is_ok(),
8773            "an HTTP/1-only HTTP listener with a sub-16393 buffer must be accepted: {accepted:?}"
8774        );
8775    }
8776
8777    fn command_socket_mode_of(mode_line: &str) -> Result<Config, ConfigError> {
8778        let toml_content = format!(
8779            r#"
8780            command_socket = "/tmp/sozu_test.sock"
8781            worker_count = 1
8782            {mode_line}
8783        "#
8784        );
8785        let file_config: FileConfig =
8786            toml::from_str(&toml_content).expect("Could not parse TOML config");
8787        ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config()
8788    }
8789
8790    #[test]
8791    fn command_socket_mode_defaults_to_0600() {
8792        let config = command_socket_mode_of("").expect("config without the key must load");
8793        assert_eq!(config.command_socket_mode, 0o600);
8794        assert_eq!(DEFAULT_COMMAND_SOCKET_MODE, 0o600);
8795    }
8796
8797    #[test]
8798    fn command_socket_mode_accepts_octal_strings() {
8799        for (value, expected) in [
8800            ("0660", 0o660),
8801            ("660", 0o660),
8802            ("0600", 0o600),
8803            ("0666", 0o666),
8804            ("777", 0o777),
8805            ("0777", 0o777),
8806            ("600", 0o600),
8807            ("0640", 0o640),
8808        ] {
8809            let config = command_socket_mode_of(&format!("command_socket_mode = \"{value}\""))
8810                .unwrap_or_else(|e| panic!("{value:?} must load: {e}"));
8811            assert_eq!(config.command_socket_mode, expected, "{value:?}");
8812        }
8813    }
8814
8815    #[test]
8816    fn command_socket_mode_rejects_invalid_values() {
8817        for value in [
8818            "", "8", "0668", "abc", "0o660", "0x1b6", "-660", "+660", " 660", "660 ", "1660",
8819            "2770", "4755", "7777", "01000", "00660", "0", "0000", "0066", "0200", "0400", "0466",
8820        ] {
8821            match command_socket_mode_of(&format!("command_socket_mode = \"{value}\"")) {
8822                Err(ConfigError::InvalidCommandSocketMode {
8823                    value: rejected, ..
8824                }) => {
8825                    assert_eq!(rejected, value)
8826                }
8827                other => panic!("{value:?} must be rejected, got {other:?}"),
8828            }
8829        }
8830    }
8831
8832    #[test]
8833    fn command_socket_mode_rejects_a_toml_integer() {
8834        // `660` as a TOML integer is decimal 660 = 0o1224: refuse the type
8835        // rather than guess which base the operator meant. The refusal is a
8836        // TOML deserialization error, raised before `parse_command_socket_mode`.
8837        let dir = tempfile::tempdir().expect("tempdir");
8838        let path = dir.path().join("config.toml");
8839        std::fs::write(
8840            &path,
8841            "command_socket = \"/tmp/sozu_test.sock\"\ncommand_socket_mode = 660\n",
8842        )
8843        .expect("write config");
8844        match FileConfig::load_from_path(path.to_str().expect("utf-8 path")) {
8845            Err(ConfigError::DeserializeToml(message)) => assert!(
8846                message.contains("config.toml") && message.contains(" at bytes "),
8847                "the diagnostic keeps the path and the byte range: {message}"
8848            ),
8849            other => panic!("an integer mode must fail as DeserializeToml, got {other:?}"),
8850        }
8851    }
8852
8853    #[test]
8854    fn config_without_command_socket_mode_deserializes_to_0600() {
8855        // An upgrade snapshot written by a main process that predates the key
8856        // carries a `Config` without it.
8857        let mut encoded = serde_json::to_value(Config::default()).expect("serialize Config");
8858        encoded
8859            .as_object_mut()
8860            .expect("Config serializes to an object")
8861            .remove("command_socket_mode")
8862            .expect("Config serializes command_socket_mode");
8863        let decoded: Config = serde_json::from_value(encoded).expect("deserialize Config");
8864        assert_eq!(decoded.command_socket_mode, 0o600);
8865    }
8866
8867    #[test]
8868    fn command_buffer_size_exceeds_max_rejected() {
8869        // command_buffer_size > max_command_buffer_size must be rejected at load
8870        // (sozu-proxy/sozu#1416): otherwise the real command Channel::new starts its
8871        // front/back buffers larger than the ceiling max_buffer_size is supposed to
8872        // enforce.
8873        let toml_content = r#"
8874            command_socket = "/tmp/sozu_test.sock"
8875            worker_count = 1
8876            command_buffer_size = 2000000
8877            max_command_buffer_size = 1000000
8878        "#;
8879        let file_config: FileConfig =
8880            toml::from_str(toml_content).expect("Could not parse TOML config");
8881        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8882        match result {
8883            Err(ConfigError::CommandBufferSizeExceedsMax {
8884                command_buffer_size: 2_000_000,
8885                max_command_buffer_size: 1_000_000,
8886            }) => {}
8887            other => panic!("expected CommandBufferSizeExceedsMax, got {other:?}"),
8888        }
8889    }
8890
8891    #[test]
8892    fn command_buffer_size_equal_to_max_accepted() {
8893        // The boundary itself (command_buffer_size == max_command_buffer_size) must
8894        // load: `Channel::new` allocates exactly at the ceiling, never above it.
8895        let toml_content = r#"
8896            command_socket = "/tmp/sozu_test.sock"
8897            worker_count = 1
8898            command_buffer_size = 1000000
8899            max_command_buffer_size = 1000000
8900        "#;
8901        let file_config: FileConfig =
8902            toml::from_str(toml_content).expect("Could not parse TOML config");
8903        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8904        assert!(
8905            result.is_ok(),
8906            "command_buffer_size == max_command_buffer_size should be accepted: {result:?}"
8907        );
8908    }
8909
8910    #[test]
8911    fn buffer_size_at_h2_minimum_accepted() {
8912        let toml_content = r#"
8913            command_socket = "/tmp/sozu_test.sock"
8914            worker_count = 1
8915            buffer_size = 16393
8916
8917            [[listeners]]
8918            protocol = "https"
8919            address = "127.0.0.1:8443"
8920        "#;
8921        let file_config: FileConfig =
8922            toml::from_str(toml_content).expect("Could not parse TOML config");
8923        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8924        assert!(
8925            result.is_ok(),
8926            "buffer_size at the H2 minimum should be accepted: {result:?}"
8927        );
8928    }
8929
8930    #[test]
8931    fn alpn_protocols_default() {
8932        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8933        let config = builder.to_tls(None).expect("to_tls should succeed");
8934        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
8935    }
8936
8937    #[test]
8938    fn alpn_protocols_custom() {
8939        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8940        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned()]));
8941        let config = builder.to_tls(None).expect("to_tls should succeed");
8942        assert_eq!(config.alpn_protocols, vec!["http/1.1"]);
8943    }
8944
8945    #[test]
8946    fn alpn_protocols_invalid_rejected() {
8947        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8948        builder.with_alpn_protocols(Some(vec!["h3".to_owned()]));
8949        let result = builder.to_tls(None);
8950        assert!(result.is_err());
8951        let err = result.unwrap_err();
8952        assert!(
8953            err.to_string().contains("h3"),
8954            "error should mention the invalid protocol: {err}"
8955        );
8956    }
8957
8958    #[test]
8959    fn alpn_protocols_empty_uses_default() {
8960        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8961        builder.with_alpn_protocols(Some(vec![]));
8962        let config = builder.to_tls(None).expect("to_tls should succeed");
8963        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
8964    }
8965
8966    #[test]
8967    fn alpn_protocols_deduplicated() {
8968        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8969        builder.with_alpn_protocols(Some(vec![
8970            "h2".to_owned(),
8971            "h2".to_owned(),
8972            "http/1.1".to_owned(),
8973        ]));
8974        let config = builder.to_tls(None).expect("to_tls should succeed");
8975        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
8976    }
8977
8978    #[test]
8979    fn alpn_protocols_order_preserved() {
8980        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8981        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned(), "h2".to_owned()]));
8982        let config = builder.to_tls(None).expect("to_tls should succeed");
8983        assert_eq!(config.alpn_protocols, vec!["http/1.1", "h2"]);
8984    }
8985
8986    /// CRLF or NUL in a `[[clusters.<id>.frontends.headers]]` value
8987    /// would let an operator-supplied config splice arbitrary
8988    /// header / request lines into the H1 wire on the backend side
8989    /// (CWE-113). The H2 emission path filters at runtime; we reject
8990    /// at config-load time as a defense in depth.
8991    #[test]
8992    fn parse_header_edit_rejects_crlf_in_value() {
8993        let entry = HeaderEditConfig {
8994            position: "request".to_owned(),
8995            key: "X-Test".to_owned(),
8996            value: "value\r\nEvil-Header: stolen".to_owned(),
8997        };
8998        let err = parse_header_edit(0, &entry).expect_err("CRLF in value must be rejected");
8999        match err {
9000            ConfigError::InvalidHeaderBytes { index, field } => {
9001                assert_eq!(index, 0);
9002                assert_eq!(field, "value");
9003            }
9004            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
9005        }
9006    }
9007
9008    #[test]
9009    fn parse_header_edit_rejects_lf_in_key() {
9010        let entry = HeaderEditConfig {
9011            position: "response".to_owned(),
9012            key: "X-\nTest".to_owned(),
9013            value: "ok".to_owned(),
9014        };
9015        let err = parse_header_edit(2, &entry).expect_err("LF in key must be rejected");
9016        match err {
9017            ConfigError::InvalidHeaderBytes { index, field } => {
9018                assert_eq!(index, 2);
9019                assert_eq!(field, "key");
9020            }
9021            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
9022        }
9023    }
9024
9025    #[test]
9026    fn parse_header_edit_rejects_nul() {
9027        let entry = HeaderEditConfig {
9028            position: "both".to_owned(),
9029            key: "X-Test".to_owned(),
9030            value: "with\0nul".to_owned(),
9031        };
9032        assert!(matches!(
9033            parse_header_edit(0, &entry),
9034            Err(ConfigError::InvalidHeaderBytes { .. })
9035        ));
9036    }
9037
9038    /// Horizontal tab `\t` (0x09) is permitted in field values per
9039    /// RFC 9110 §5.5 (folded-header obs-fold parts). The value-side
9040    /// validator must NOT reject it — otherwise legitimate operator
9041    /// configs (e.g. `Authorization: Basic\tCREDENTIALS`) become
9042    /// unusable. The key-side validator IS stricter (token grammar).
9043    #[test]
9044    fn parse_header_edit_accepts_tab_in_value() {
9045        let entry = HeaderEditConfig {
9046            position: "request".to_owned(),
9047            key: "X-Test".to_owned(),
9048            value: "with\ttab".to_owned(),
9049        };
9050        let header = parse_header_edit(0, &entry).expect("tab in value must be accepted");
9051        assert_eq!(header.val, "with\ttab");
9052    }
9053
9054    /// Header NAMES follow `token` grammar per RFC 9110 §5.1. HTAB and
9055    /// SP are NOT tchar; the key-side validator must reject them even
9056    /// though the value-side validator permits HTAB. Without this,
9057    /// an operator entry like `key = "Host\t"` would emit `Host\t: …`
9058    /// on the H1 wire and produce an invalid (but parser-tolerant)
9059    /// header line that some backends silently accept as `Host:`.
9060    #[test]
9061    fn parse_header_edit_rejects_tab_in_key() {
9062        let entry = HeaderEditConfig {
9063            position: "request".to_owned(),
9064            key: "Host\t".to_owned(),
9065            value: "ok".to_owned(),
9066        };
9067        let err = parse_header_edit(0, &entry).expect_err("HTAB in key must be rejected");
9068        match err {
9069            ConfigError::InvalidHeaderBytes { field, .. } => assert_eq!(field, "key"),
9070            other => panic!("expected InvalidHeaderBytes{{field=\"key\"}}, got {other:?}"),
9071        }
9072    }
9073
9074    #[test]
9075    fn parse_header_edit_rejects_space_in_key() {
9076        let entry = HeaderEditConfig {
9077            position: "request".to_owned(),
9078            key: "X Test".to_owned(),
9079            value: "ok".to_owned(),
9080        };
9081        let err = parse_header_edit(0, &entry).expect_err("SP in key must be rejected");
9082        assert!(matches!(err, ConfigError::InvalidHeaderBytes { .. }));
9083    }
9084
9085    #[test]
9086    fn parse_header_edit_rejects_empty_key() {
9087        let entry = HeaderEditConfig {
9088            position: "request".to_owned(),
9089            key: String::new(),
9090            value: "ok".to_owned(),
9091        };
9092        let err = parse_header_edit(0, &entry).expect_err("empty key must be rejected");
9093        assert!(matches!(
9094            err,
9095            ConfigError::InvalidHeaderBytes { field: "key", .. }
9096        ));
9097    }
9098
9099    #[test]
9100    fn parse_header_edit_accepts_clean_value() {
9101        let entry = HeaderEditConfig {
9102            position: "request".to_owned(),
9103            key: "X-Tenant".to_owned(),
9104            value: "alpha".to_owned(),
9105        };
9106        let header = parse_header_edit(0, &entry).expect("clean value must be accepted");
9107        assert_eq!(header.key, "X-Tenant");
9108        assert_eq!(header.val, "alpha");
9109    }
9110
9111    /// A bare string with no scheme prefix is the inline literal body.
9112    /// This is the common case — short canned responses inline in TOML
9113    /// or a `--answer` flag, no disk I/O.
9114    #[test]
9115    fn resolve_answer_source_bare_string_is_literal() {
9116        let body = resolve_answer_source("HTTP/1.1 503 Service Unavailable\r\n\r\nbusy")
9117            .expect("bare-string source must resolve");
9118        assert_eq!(body, "HTTP/1.1 503 Service Unavailable\r\n\r\nbusy");
9119    }
9120
9121    #[test]
9122    fn resolve_answer_source_empty_string_is_legitimate() {
9123        let body = resolve_answer_source("").expect("empty source must resolve");
9124        assert_eq!(body, "");
9125    }
9126
9127    /// `file://` opts into reading the path off disk. A non-existent
9128    /// path bubbles up as `ConfigError::FileOpen` so the operator gets
9129    /// the same diagnostics as the existing per-status `answer_NNN`
9130    /// flow.
9131    #[test]
9132    fn resolve_answer_source_file_scheme_missing_file_errors() {
9133        let err = resolve_answer_source("file:///nonexistent/sozu-test/never.http")
9134            .expect_err("missing path must error");
9135        assert!(matches!(err, ConfigError::FileOpen { .. }));
9136    }
9137
9138    /// `file://` strips the scheme; an empty path after the scheme is
9139    /// rejected (empty path on filesystem read).
9140    #[test]
9141    fn resolve_answer_source_file_scheme_empty_path_errors() {
9142        let err = resolve_answer_source("file://").expect_err("empty path must error");
9143        assert!(matches!(err, ConfigError::FileOpen { .. }));
9144    }
9145
9146    // ── TCP SNI/ALPN frontends (sozu-proxy/sozu#1279) ───────────────────────
9147
9148    /// A legacy-style TCP frontend TOML (no `hostname`/`alpn` keys at all)
9149    /// must still parse, and the built frontend/listener must carry the
9150    /// same values as before this feature existed: `sni = None`,
9151    /// `alpn = []`, and the proto default SNI-preread knobs (unused since
9152    /// no SNI frontend targets the listener).
9153    #[test]
9154    fn legacy_tcp_frontend_toml_without_sni_alpn_parses_unchanged() {
9155        let toml_content = r#"
9156            command_socket = "/tmp/sozu_test.sock"
9157            worker_count = 1
9158
9159            [[listeners]]
9160            protocol = "tcp"
9161            address  = "127.0.0.1:9000"
9162
9163            [clusters.legacy]
9164            protocol       = "tcp"
9165            load_balancing = "ROUND_ROBIN"
9166            frontends = [
9167              { address = "127.0.0.1:9000" }
9168            ]
9169            backends = [
9170              { address = "10.0.0.1:9000" }
9171            ]
9172        "#;
9173        let file_config: FileConfig =
9174            toml::from_str(toml_content).expect("Could not parse legacy TCP TOML");
9175        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9176            .into_config()
9177            .expect("legacy TCP config without sni/alpn must load unchanged");
9178
9179        assert_eq!(config.tcp_listeners.len(), 1);
9180        let listener = &config.tcp_listeners[0];
9181        assert_eq!(
9182            listener.sni_preread_timeout,
9183            Some(DEFAULT_SNI_PREREAD_TIMEOUT),
9184            "proto default sni_preread_timeout must be populated even though unused"
9185        );
9186        assert_eq!(
9187            listener.sni_preread_max_bytes,
9188            Some(DEFAULT_SNI_PREREAD_MAX_BYTES),
9189            "proto default sni_preread_max_bytes must be populated even though unused"
9190        );
9191
9192        let messages = config
9193            .generate_config_messages()
9194            .expect("Could not generate config messages");
9195        let tcp_frontend = messages
9196            .iter()
9197            .find_map(|m| match &m.content.request_type {
9198                Some(RequestType::AddTcpFrontend(f)) => Some(f),
9199                _ => None,
9200            })
9201            .expect("AddTcpFrontend must be present");
9202        assert_eq!(tcp_frontend.sni, None, "legacy frontend must carry no sni");
9203        assert!(
9204            tcp_frontend.alpn.is_empty(),
9205            "legacy frontend must carry no alpn"
9206        );
9207    }
9208
9209    /// sozu#633: the catch-all frontend `doc/configure.md` documents —
9210    /// `hostname = "*"` with `position = "POST"` — loads from TOML and
9211    /// reaches the worker as a `Post` rule on `*`. The position is an enum
9212    /// spelled in upper case: `"Post"` is refused with the serde message the
9213    /// documentation quotes, rather than silently falling back to `TREE`.
9214    ///
9215    /// To SEE THIS RED: in `command/build.rs`, drop the
9216    /// `#[serde(rename_all = "SCREAMING_SNAKE_CASE")]` enum attribute. The
9217    /// first `toml::from_str` then fails with
9218    /// "unknown variant `POST`, expected one of `Pre`, `Post`, `Tree`".
9219    #[test]
9220    fn a_catch_all_post_frontend_loads_from_toml_and_rejects_a_mixed_case_position() {
9221        let catch_all = |position: &str| {
9222            format!(
9223                r#"
9224            command_socket = "/tmp/sozu_test.sock"
9225            worker_count = 1
9226
9227            [[listeners]]
9228            protocol = "http"
9229            address  = "127.0.0.1:8080"
9230
9231            [clusters.fallback]
9232            protocol = "http"
9233            frontends = [
9234              {{ address = "127.0.0.1:8080", hostname = "*", position = "{position}" }}
9235            ]
9236            backends = [
9237              {{ address = "10.0.0.1:8000" }}
9238            ]
9239        "#
9240            )
9241        };
9242
9243        let file_config: FileConfig = toml::from_str(&catch_all("POST"))
9244            .expect("an upper-case `position = \"POST\"` must parse");
9245        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9246            .into_config()
9247            .expect("a catch-all POST frontend must load");
9248        let messages = config
9249            .generate_config_messages()
9250            .expect("Could not generate config messages");
9251        let frontend = messages
9252            .iter()
9253            .find_map(|m| match &m.content.request_type {
9254                Some(RequestType::AddHttpFrontend(f)) => Some(f),
9255                _ => None,
9256            })
9257            .expect("AddHttpFrontend must be present");
9258        assert_eq!(frontend.hostname, "*");
9259        assert_eq!(frontend.position, RulePosition::Post as i32);
9260
9261        let error = toml::from_str::<FileConfig>(&catch_all("Post"))
9262            .expect_err("a mixed-case `position = \"Post\"` must be refused")
9263            .to_string();
9264        assert!(
9265            error.contains("unknown variant `Post`, expected one of `PRE`, `POST`, `TREE`"),
9266            "unexpected error: {error}",
9267        );
9268    }
9269
9270    /// sozu#322: `forwarded_headers` is a per-listener enum spelled
9271    /// `both`, `x_forwarded`, `rfc7239` or `none`. Each value reaches the
9272    /// wire config of an HTTP and an HTTPS listener, an absent key is
9273    /// `both` (the historical behaviour), and any other spelling is refused
9274    /// by serde instead of silently falling back to the default.
9275    #[test]
9276    fn forwarded_headers_parses_every_mode_defaults_to_both_and_rejects_others() {
9277        let listeners = |value: Option<&str>| {
9278            let line = value
9279                .map(|value| format!("forwarded_headers = \"{value}\""))
9280                .unwrap_or_default();
9281            format!(
9282                r#"
9283            command_socket = "/tmp/sozu_test.sock"
9284            worker_count = 1
9285
9286            [[listeners]]
9287            protocol = "http"
9288            address  = "127.0.0.1:8080"
9289            {line}
9290
9291            [[listeners]]
9292            protocol = "https"
9293            address  = "127.0.0.1:8443"
9294            {line}
9295            "#
9296            )
9297        };
9298        let load = |value: Option<&str>| {
9299            let file_config: FileConfig =
9300                toml::from_str(&listeners(value)).expect("the listeners must parse");
9301            let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9302                .into_config()
9303                .expect("the listeners must load");
9304            (
9305                config.http_listeners[0].forwarded_headers,
9306                config.https_listeners[0].forwarded_headers,
9307            )
9308        };
9309
9310        for (spelling, expected) in [
9311            (None, ForwardedHeaders::Both),
9312            (Some("both"), ForwardedHeaders::Both),
9313            (Some("x_forwarded"), ForwardedHeaders::XForwarded),
9314            (Some("rfc7239"), ForwardedHeaders::Rfc7239),
9315            (Some("none"), ForwardedHeaders::None),
9316        ] {
9317            let expected = Some(expected as i32);
9318            assert_eq!(load(spelling), (expected, expected), "{spelling:?}");
9319        }
9320
9321        for invalid in ["RFC7239", "x-forwarded", "forwarded", ""] {
9322            let error = toml::from_str::<FileConfig>(&listeners(Some(invalid)))
9323                .expect_err("an unknown forwarded_headers value must be refused")
9324                .to_string();
9325            assert!(
9326                error.contains(&format!(
9327                    "unknown variant `{invalid}`, expected one of `both`, `x_forwarded`, `rfc7239`, `none`"
9328                )),
9329                "{invalid:?}: unexpected error: {error}",
9330            );
9331        }
9332    }
9333
9334    /// `hostname` on a TCP frontend maps to the wire `sni` field, exact
9335    /// hostnames and a single leading `*.` wildcard are both accepted.
9336    #[test]
9337    fn tcp_frontend_hostname_maps_to_sni_exact_and_wildcard() {
9338        let toml_content = r#"
9339            command_socket = "/tmp/sozu_test.sock"
9340            worker_count = 1
9341
9342            [[listeners]]
9343            protocol = "tcp"
9344            address  = "127.0.0.1:9010"
9345
9346            [clusters.exact]
9347            protocol       = "tcp"
9348            load_balancing = "ROUND_ROBIN"
9349            frontends = [
9350              { address = "127.0.0.1:9010", hostname = "example.com", alpn = ["h2"] }
9351            ]
9352            backends = [ { address = "10.0.0.1:9010" } ]
9353
9354            [clusters.wildcard]
9355            protocol       = "tcp"
9356            load_balancing = "ROUND_ROBIN"
9357            frontends = [
9358              { address = "127.0.0.1:9010", hostname = "*.example.com" }
9359            ]
9360            backends = [ { address = "10.0.0.2:9010" } ]
9361        "#;
9362        let file_config: FileConfig =
9363            toml::from_str(toml_content).expect("Could not parse TOML config");
9364        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9365            .into_config()
9366            .expect("exact + wildcard SNI frontends on distinct sni must load");
9367
9368        let messages = config
9369            .generate_config_messages()
9370            .expect("Could not generate config messages");
9371        let mut frontends: Vec<_> = messages
9372            .iter()
9373            .filter_map(|m| match &m.content.request_type {
9374                Some(RequestType::AddTcpFrontend(f)) => Some(f.clone()),
9375                _ => None,
9376            })
9377            .collect();
9378        frontends.sort_by(|a, b| a.cluster_id.cmp(&b.cluster_id));
9379
9380        assert_eq!(frontends.len(), 2);
9381        assert_eq!(frontends[0].sni, Some("example.com".to_string()));
9382        assert_eq!(frontends[0].alpn, vec!["h2".to_string()]);
9383        assert_eq!(frontends[1].sni, Some("*.example.com".to_string()));
9384        assert!(frontends[1].alpn.is_empty());
9385    }
9386
9387    /// (a) An SNI pattern with more than one wildcard label, an embedded
9388    /// `*`, an empty label, or any `/` (a leftmost `/.../` label would be
9389    /// inserted into the `pattern_trie` route table as a REGEX segment,
9390    /// silently widening routing) is rejected at config-load.
9391    #[test]
9392    fn tcp_frontend_invalid_sni_pattern_rejected() {
9393        for invalid in [
9394            "*.*.example.com",
9395            "foo.*.com",
9396            "*",
9397            "example..com",
9398            "",
9399            "/[a-z]+/.example.com",
9400            "foo/bar.example.com",
9401        ] {
9402            let frontend = FileClusterFrontendConfig {
9403                address: "127.0.0.1:8080".parse().unwrap(),
9404                hostname: Some(invalid.to_string()),
9405                alpn: vec![],
9406                path: None,
9407                path_type: None,
9408                method: None,
9409                certificate: None,
9410                key: None,
9411                certificate_chain: None,
9412                tls_versions: vec![],
9413                position: RulePosition::Tree,
9414                tags: None,
9415                redirect: None,
9416                redirect_scheme: None,
9417                redirect_template: None,
9418                rewrite_host: None,
9419                rewrite_path: None,
9420                rewrite_port: None,
9421                required_auth: None,
9422                headers: None,
9423                hsts: None,
9424                header_predicates: None,
9425            };
9426            match frontend.to_tcp_front() {
9427                Err(ConfigError::InvalidSniPattern { sni }) => assert_eq!(sni, invalid),
9428                other => panic!("expected InvalidSniPattern for {invalid:?}, got {other:?}"),
9429            }
9430        }
9431    }
9432
9433    /// (a) A non-ASCII SNI pattern is rejected loudly: on-wire SNI is
9434    /// always an ASCII A-label (RFC 6066 / IDNA), so a Unicode U-label in
9435    /// the config would load fine but never match a ClientHello — a
9436    /// silent routing failure. The error names the punycode form the
9437    /// operator must write instead.
9438    #[test]
9439    fn tcp_frontend_non_ascii_sni_pattern_rejected() {
9440        for non_ascii in ["münchen.example", "*.bücher.example", "日本.example"] {
9441            let frontend = FileClusterFrontendConfig {
9442                address: "127.0.0.1:8080".parse().unwrap(),
9443                hostname: Some(non_ascii.to_string()),
9444                alpn: vec![],
9445                path: None,
9446                path_type: None,
9447                method: None,
9448                certificate: None,
9449                key: None,
9450                certificate_chain: None,
9451                tls_versions: vec![],
9452                position: RulePosition::Tree,
9453                tags: None,
9454                redirect: None,
9455                redirect_scheme: None,
9456                redirect_template: None,
9457                rewrite_host: None,
9458                rewrite_path: None,
9459                rewrite_port: None,
9460                required_auth: None,
9461                headers: None,
9462                hsts: None,
9463                header_predicates: None,
9464            };
9465            match frontend.to_tcp_front() {
9466                Err(ConfigError::NonAsciiSniPattern { sni }) => assert_eq!(sni, non_ascii),
9467                other => panic!("expected NonAsciiSniPattern for {non_ascii:?}, got {other:?}"),
9468            }
9469        }
9470    }
9471
9472    /// The punycode A-label form of an internationalized hostname — what
9473    /// the NonAsciiSniPattern error tells the operator to write — is
9474    /// accepted, both exact and wildcarded, and case-normalized like any
9475    /// other ASCII pattern.
9476    #[test]
9477    fn tcp_frontend_punycode_sni_pattern_accepted() {
9478        assert_eq!(
9479            validate_sni_pattern("xn--mnchen-3ya.example").expect("A-label must be accepted"),
9480            "xn--mnchen-3ya.example"
9481        );
9482        assert_eq!(
9483            validate_sni_pattern("*.xn--bcher-kva.example")
9484                .expect("wildcarded A-label must be accepted"),
9485            "*.xn--bcher-kva.example"
9486        );
9487        assert_eq!(
9488            validate_sni_pattern("XN--MNCHEN-3YA.Example")
9489                .expect("mixed-case A-label must be accepted"),
9490            "xn--mnchen-3ya.example",
9491            "A-label patterns are ASCII-lowercased like any other pattern"
9492        );
9493    }
9494
9495    /// `alpn` is a TCP-only concept; setting it on an HTTP frontend is
9496    /// rejected rather than silently ignored.
9497    #[test]
9498    fn alpn_rejected_on_http_frontend() {
9499        let frontend = FileClusterFrontendConfig {
9500            address: "127.0.0.1:8080".parse().unwrap(),
9501            hostname: Some("example.com".to_owned()),
9502            alpn: vec!["h2".to_string()],
9503            path: None,
9504            path_type: None,
9505            method: None,
9506            certificate: None,
9507            key: None,
9508            certificate_chain: None,
9509            tls_versions: vec![],
9510            position: RulePosition::Tree,
9511            tags: None,
9512            redirect: None,
9513            redirect_scheme: None,
9514            redirect_template: None,
9515            rewrite_host: None,
9516            rewrite_path: None,
9517            rewrite_port: None,
9518            required_auth: None,
9519            headers: None,
9520            hsts: None,
9521            header_predicates: None,
9522        };
9523        match frontend.to_http_front("api") {
9524            Err(ConfigError::InvalidFrontendConfig(field)) => assert_eq!(field, "alpn"),
9525            other => panic!("expected InvalidFrontendConfig(\"alpn\"), got {other:?}"),
9526        }
9527    }
9528
9529    /// `header_predicates` is an HTTP-only guard; a TCP frontend that sets it
9530    /// is rejected instead of silently routing unconditionally.
9531    #[test]
9532    fn header_predicates_rejected_on_tcp_frontend() {
9533        let frontend = FileClusterFrontendConfig {
9534            address: "127.0.0.1:8080".parse().unwrap(),
9535            hostname: None,
9536            alpn: vec![],
9537            path: None,
9538            path_type: None,
9539            method: None,
9540            certificate: None,
9541            key: None,
9542            certificate_chain: None,
9543            tls_versions: vec![],
9544            position: RulePosition::Tree,
9545            tags: None,
9546            redirect: None,
9547            redirect_scheme: None,
9548            redirect_template: None,
9549            rewrite_host: None,
9550            rewrite_path: None,
9551            rewrite_port: None,
9552            required_auth: None,
9553            headers: None,
9554            hsts: None,
9555            header_predicates: Some(vec![]),
9556        };
9557        match frontend.to_tcp_front() {
9558            Err(ConfigError::InvalidFrontendConfig(field)) => {
9559                assert_eq!(field, "header_predicates")
9560            }
9561            other => panic!("expected InvalidFrontendConfig(\"header_predicates\"), got {other:?}"),
9562        }
9563    }
9564
9565    /// A TCP frontend that sets `alpn` but leaves `hostname` (the wire
9566    /// `sni` field) unset is a config error: an ALPN matcher only ever gets
9567    /// consulted from within the SNI-scoped preread route table, so a
9568    /// no-SNI frontend would install the raw catch-all path and silently
9569    /// never enforce the configured protocol list.
9570    #[test]
9571    fn tcp_frontend_alpn_without_sni_rejected() {
9572        let toml_content = r#"
9573            command_socket = "/tmp/sozu_test.sock"
9574            worker_count = 1
9575
9576            [[listeners]]
9577            protocol = "tcp"
9578            address  = "127.0.0.1:9019"
9579
9580            [clusters.a]
9581            protocol       = "tcp"
9582            load_balancing = "ROUND_ROBIN"
9583            frontends = [
9584              { address = "127.0.0.1:9019", alpn = ["h2"] }
9585            ]
9586            backends = [ { address = "10.0.0.1:9019" } ]
9587        "#;
9588        let file_config: FileConfig =
9589            toml::from_str(toml_content).expect("Could not parse TOML config");
9590        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9591        match result {
9592            Err(ConfigError::AlpnWithoutSni { address }) => {
9593                assert_eq!(address.to_string(), "127.0.0.1:9019");
9594            }
9595            other => panic!("expected AlpnWithoutSni, got {other:?}"),
9596        }
9597    }
9598
9599    /// (b) Two TCP frontends on the same (address, sni) advertising an
9600    /// overlapping ALPN protocol is a config error — routing on that
9601    /// listener would otherwise depend on iteration order.
9602    #[test]
9603    fn tcp_frontend_alpn_overlap_rejected() {
9604        let toml_content = r#"
9605            command_socket = "/tmp/sozu_test.sock"
9606            worker_count = 1
9607
9608            [[listeners]]
9609            protocol = "tcp"
9610            address  = "127.0.0.1:9020"
9611
9612            [clusters.a]
9613            protocol       = "tcp"
9614            load_balancing = "ROUND_ROBIN"
9615            frontends = [
9616              { address = "127.0.0.1:9020", hostname = "example.com", alpn = ["h2"] }
9617            ]
9618            backends = [ { address = "10.0.0.1:9020" } ]
9619
9620            [clusters.b]
9621            protocol       = "tcp"
9622            load_balancing = "ROUND_ROBIN"
9623            frontends = [
9624              { address = "127.0.0.1:9020", hostname = "example.com", alpn = ["h2", "http/1.1"] }
9625            ]
9626            backends = [ { address = "10.0.0.2:9020" } ]
9627        "#;
9628        let file_config: FileConfig =
9629            toml::from_str(toml_content).expect("Could not parse TOML config");
9630        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9631        match result {
9632            Err(ConfigError::TcpFrontendAlpnOverlap { protocol, .. }) => {
9633                assert_eq!(protocol, "h2");
9634            }
9635            other => panic!("expected TcpFrontendAlpnOverlap, got {other:?}"),
9636        }
9637    }
9638
9639    /// (b) At most one TCP frontend per (address, sni) may leave `alpn`
9640    /// empty (the catch-all match); a second is as ambiguous as an
9641    /// overlapping explicit protocol.
9642    #[test]
9643    fn tcp_frontend_multiple_alpn_catch_all_rejected() {
9644        let toml_content = r#"
9645            command_socket = "/tmp/sozu_test.sock"
9646            worker_count = 1
9647
9648            [[listeners]]
9649            protocol = "tcp"
9650            address  = "127.0.0.1:9021"
9651
9652            [clusters.a]
9653            protocol       = "tcp"
9654            load_balancing = "ROUND_ROBIN"
9655            frontends = [
9656              { address = "127.0.0.1:9021", hostname = "example.com" }
9657            ]
9658            backends = [ { address = "10.0.0.1:9021" } ]
9659
9660            [clusters.b]
9661            protocol       = "tcp"
9662            load_balancing = "ROUND_ROBIN"
9663            frontends = [
9664              { address = "127.0.0.1:9021", hostname = "example.com" }
9665            ]
9666            backends = [ { address = "10.0.0.2:9021" } ]
9667        "#;
9668        let file_config: FileConfig =
9669            toml::from_str(toml_content).expect("Could not parse TOML config");
9670        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9671        assert!(
9672            matches!(
9673                result,
9674                Err(ConfigError::TcpFrontendMultipleAlpnCatchAll { .. })
9675            ),
9676            "expected TcpFrontendMultipleAlpnCatchAll, got {result:?}"
9677        );
9678    }
9679
9680    /// (c) A listener targeted by both a no-SNI frontend and an SNI-scoped
9681    /// frontend is a config error: an SNI-enabled listener must not also
9682    /// carry a raw-TCP fallback.
9683    #[test]
9684    fn tcp_listener_mixes_sni_and_no_sni_rejected() {
9685        let toml_content = r#"
9686            command_socket = "/tmp/sozu_test.sock"
9687            worker_count = 1
9688
9689            [[listeners]]
9690            protocol = "tcp"
9691            address  = "127.0.0.1:9022"
9692
9693            [clusters.a]
9694            protocol       = "tcp"
9695            load_balancing = "ROUND_ROBIN"
9696            frontends = [
9697              { address = "127.0.0.1:9022", hostname = "example.com" }
9698            ]
9699            backends = [ { address = "10.0.0.1:9022" } ]
9700
9701            [clusters.b]
9702            protocol       = "tcp"
9703            load_balancing = "ROUND_ROBIN"
9704            frontends = [
9705              { address = "127.0.0.1:9022" }
9706            ]
9707            backends = [ { address = "10.0.0.2:9022" } ]
9708        "#;
9709        let file_config: FileConfig =
9710            toml::from_str(toml_content).expect("Could not parse TOML config");
9711        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9712        assert!(
9713            matches!(result, Err(ConfigError::TcpListenerMixesSniAndNoSni { .. })),
9714            "expected TcpListenerMixesSniAndNoSni, got {result:?}"
9715        );
9716    }
9717
9718    /// (d) `sni_preread_timeout` (proto default: 5s) exceeding the
9719    /// listener's `front_timeout` is rejected — but only when an SNI
9720    /// frontend actually targets that listener, so legacy TCP-only
9721    /// configs with a low front_timeout keep loading unchanged.
9722    #[test]
9723    fn sni_preread_timeout_exceeding_front_timeout_rejected() {
9724        let toml_content = r#"
9725            command_socket = "/tmp/sozu_test.sock"
9726            worker_count = 1
9727
9728            [[listeners]]
9729            protocol      = "tcp"
9730            address       = "127.0.0.1:9030"
9731            front_timeout = 2
9732
9733            [clusters.a]
9734            protocol       = "tcp"
9735            load_balancing = "ROUND_ROBIN"
9736            frontends = [
9737              { address = "127.0.0.1:9030", hostname = "example.com" }
9738            ]
9739            backends = [ { address = "10.0.0.1:9030" } ]
9740        "#;
9741        let file_config: FileConfig =
9742            toml::from_str(toml_content).expect("Could not parse TOML config");
9743        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9744        match result {
9745            Err(ConfigError::SniPrereadTimeoutExceedsFrontTimeout {
9746                sni_preread_timeout: 5,
9747                front_timeout: 2,
9748                ..
9749            }) => {}
9750            other => panic!("expected SniPrereadTimeoutExceedsFrontTimeout, got {other:?}"),
9751        }
9752    }
9753
9754    /// (e) `sni_preread_max_bytes` (proto default: 16384) exceeding the
9755    /// global `buffer_size` is rejected — again only when an SNI frontend
9756    /// targets the listener.
9757    #[test]
9758    fn sni_preread_max_bytes_exceeding_buffer_size_rejected() {
9759        let toml_content = r#"
9760            command_socket = "/tmp/sozu_test.sock"
9761            worker_count = 1
9762            buffer_size = 8192
9763
9764            [[listeners]]
9765            protocol = "tcp"
9766            address  = "127.0.0.1:9031"
9767
9768            [clusters.a]
9769            protocol       = "tcp"
9770            load_balancing = "ROUND_ROBIN"
9771            frontends = [
9772              { address = "127.0.0.1:9031", hostname = "example.com" }
9773            ]
9774            backends = [ { address = "10.0.0.1:9031" } ]
9775        "#;
9776        let file_config: FileConfig =
9777            toml::from_str(toml_content).expect("Could not parse TOML config");
9778        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9779        match result {
9780            Err(ConfigError::SniPrereadMaxBytesExceedsBufferSize {
9781                sni_preread_max_bytes: 16384,
9782                buffer_size: 8192,
9783                ..
9784            }) => {}
9785            other => panic!("expected SniPrereadMaxBytesExceedsBufferSize, got {other:?}"),
9786        }
9787    }
9788
9789    /// `sni_preread_max_bytes = 0` on an SNI-enabled listener is rejected:
9790    /// the preread shell would issue zero-length reads that never make
9791    /// progress, spinning until the event-loop iteration guard trips
9792    /// instead of ever reaching a routing decision.
9793    #[test]
9794    fn sni_preread_max_bytes_zero_rejected() {
9795        let toml_content = r#"
9796            command_socket = "/tmp/sozu_test.sock"
9797            worker_count = 1
9798
9799            [[listeners]]
9800            protocol             = "tcp"
9801            address              = "127.0.0.1:9033"
9802            sni_preread_max_bytes = 0
9803
9804            [clusters.a]
9805            protocol       = "tcp"
9806            load_balancing = "ROUND_ROBIN"
9807            frontends = [
9808              { address = "127.0.0.1:9033", hostname = "example.com" }
9809            ]
9810            backends = [ { address = "10.0.0.1:9033" } ]
9811        "#;
9812        let file_config: FileConfig =
9813            toml::from_str(toml_content).expect("Could not parse TOML config");
9814        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9815        match result {
9816            Err(ConfigError::SniPrereadMaxBytesTooSmall {
9817                sni_preread_max_bytes: 0,
9818                minimum: 5,
9819                ..
9820            }) => {}
9821            other => panic!("expected SniPrereadMaxBytesTooSmall, got {other:?}"),
9822        }
9823    }
9824
9825    /// The floor itself (`MIN_SNI_PREREAD_MAX_BYTES` = 5 bytes) must load
9826    /// successfully — only values strictly below it are rejected.
9827    #[test]
9828    fn sni_preread_max_bytes_at_the_floor_loads() {
9829        let toml_content = r#"
9830            command_socket = "/tmp/sozu_test.sock"
9831            worker_count = 1
9832
9833            [[listeners]]
9834            protocol             = "tcp"
9835            address              = "127.0.0.1:9034"
9836            sni_preread_max_bytes = 5
9837
9838            [clusters.a]
9839            protocol       = "tcp"
9840            load_balancing = "ROUND_ROBIN"
9841            frontends = [
9842              { address = "127.0.0.1:9034", hostname = "example.com" }
9843            ]
9844            backends = [ { address = "10.0.0.1:9034" } ]
9845        "#;
9846        let file_config: FileConfig =
9847            toml::from_str(toml_content).expect("Could not parse TOML config");
9848        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9849        assert!(
9850            result.is_ok(),
9851            "sni_preread_max_bytes at the exact floor must load: {result:?}"
9852        );
9853    }
9854
9855    /// Gating proof for (d)/(e): a TCP listener with a low front_timeout
9856    /// and small buffer_size, but *no* SNI frontend, must load unchanged —
9857    /// the new knobs are dead weight on a listener that never prereads.
9858    #[test]
9859    fn sni_preread_validation_ignored_without_sni_frontend() {
9860        let toml_content = r#"
9861            command_socket = "/tmp/sozu_test.sock"
9862            worker_count = 1
9863            buffer_size = 8192
9864
9865            [[listeners]]
9866            protocol      = "tcp"
9867            address       = "127.0.0.1:9032"
9868            front_timeout = 2
9869
9870            [clusters.a]
9871            protocol       = "tcp"
9872            load_balancing = "ROUND_ROBIN"
9873            frontends = [
9874              { address = "127.0.0.1:9032" }
9875            ]
9876            backends = [ { address = "10.0.0.1:9032" } ]
9877        "#;
9878        let file_config: FileConfig =
9879            toml::from_str(toml_content).expect("Could not parse TOML config");
9880        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9881        assert!(
9882            result.is_ok(),
9883            "a no-SNI TCP listener must ignore sni_preread validation entirely: {result:?}"
9884        );
9885    }
9886
9887    /// The actual use case sozu-proxy/sozu#1279 exists for: two TCP
9888    /// frontends sharing the same `(address, sni)` with disjoint,
9889    /// non-empty `alpn` lists must load successfully and both must be
9890    /// individually reachable — ALPN multiplexing within one SNI. The
9891    /// negative tests above only prove overlap is rejected; this proves
9892    /// non-overlap actually works.
9893    #[test]
9894    fn tcp_frontend_disjoint_alpn_same_sni_both_load() {
9895        let toml_content = r#"
9896            command_socket = "/tmp/sozu_test.sock"
9897            worker_count = 1
9898
9899            [[listeners]]
9900            protocol = "tcp"
9901            address  = "127.0.0.1:9040"
9902
9903            [clusters.h2_cluster]
9904            protocol       = "tcp"
9905            load_balancing = "ROUND_ROBIN"
9906            frontends = [
9907              { address = "127.0.0.1:9040", hostname = "example.com", alpn = ["h2"] }
9908            ]
9909            backends = [ { address = "10.0.0.1:9040" } ]
9910
9911            [clusters.http11_cluster]
9912            protocol       = "tcp"
9913            load_balancing = "ROUND_ROBIN"
9914            frontends = [
9915              { address = "127.0.0.1:9040", hostname = "example.com", alpn = ["http/1.1"] }
9916            ]
9917            backends = [ { address = "10.0.0.2:9040" } ]
9918        "#;
9919        let file_config: FileConfig =
9920            toml::from_str(toml_content).expect("Could not parse TOML config");
9921        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9922            .into_config()
9923            .expect("disjoint non-empty ALPN lists on the same (address, sni) must load");
9924
9925        let messages = config
9926            .generate_config_messages()
9927            .expect("Could not generate config messages");
9928        let mut frontends: Vec<_> = messages
9929            .iter()
9930            .filter_map(|m| match &m.content.request_type {
9931                Some(RequestType::AddTcpFrontend(f)) => Some(f.clone()),
9932                _ => None,
9933            })
9934            .collect();
9935        frontends.sort_by(|a, b| a.cluster_id.cmp(&b.cluster_id));
9936
9937        assert_eq!(frontends.len(), 2, "both frontends must be emitted");
9938        assert_eq!(frontends[0].cluster_id, "h2_cluster");
9939        assert_eq!(frontends[0].sni, Some("example.com".to_string()));
9940        assert_eq!(frontends[0].alpn, vec!["h2".to_string()]);
9941        assert_eq!(frontends[1].cluster_id, "http11_cluster");
9942        assert_eq!(frontends[1].sni, Some("example.com".to_string()));
9943        assert_eq!(frontends[1].alpn, vec!["http/1.1".to_string()]);
9944    }
9945
9946    /// A `protocol = "tcp"` cluster frontend resolved against a
9947    /// `protocol = "udp"` listener (`TcpFrontendConfig.udp = true`, set in
9948    /// `populate_clusters`) emits `AddUdpFrontend`, not `AddTcpFrontend` —
9949    /// it carries no `sni`/`alpn` on the wire and must not participate in
9950    /// the TCP-only mixing-ban / ALPN-overlap invariants. Two "tcp"
9951    /// clusters at the same UDP-listener address, one with `hostname` set
9952    /// and one without, must build and emit their messages rather than
9953    /// spuriously tripping `TcpListenerMixesSniAndNoSni`.
9954    ///
9955    /// Message GENERATION is all this asserts. A UDP address is claimed by
9956    /// exactly one frontend (`ConfigState::add_udp_frontend`), so at apply
9957    /// time `load_static_config` admits the first of these two and skips the
9958    /// second with `Skipping a config entry the state refused`. That is the
9959    /// config-file face of the same collision, and it is deliberately not
9960    /// asserted here: this test owns the SNI invariants, not admission.
9961    #[test]
9962    fn udp_routed_tcp_frontends_are_excluded_from_sni_invariants() {
9963        let toml_content = r#"
9964            command_socket = "/tmp/sozu_test.sock"
9965            worker_count = 1
9966
9967            [[listeners]]
9968            protocol = "udp"
9969            address  = "127.0.0.1:9050"
9970
9971            [clusters.a]
9972            protocol       = "tcp"
9973            load_balancing = "ROUND_ROBIN"
9974            frontends = [
9975              { address = "127.0.0.1:9050", hostname = "example.com" }
9976            ]
9977            backends = [ { address = "10.0.0.1:9050" } ]
9978
9979            [clusters.b]
9980            protocol       = "tcp"
9981            load_balancing = "ROUND_ROBIN"
9982            frontends = [
9983              { address = "127.0.0.1:9050" }
9984            ]
9985            backends = [ { address = "10.0.0.2:9050" } ]
9986        "#;
9987        let file_config: FileConfig =
9988            toml::from_str(toml_content).expect("Could not parse TOML config");
9989        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9990        assert!(
9991            result.is_ok(),
9992            "UDP-routed tcp-cluster frontends must not trip the SNI mixing-ban: {result:?}"
9993        );
9994
9995        let config = result.expect("checked is_ok above");
9996        let messages = config
9997            .generate_config_messages()
9998            .expect("Could not generate config messages");
9999        let add_udp_frontend_count = messages
10000            .iter()
10001            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpFrontend(_))))
10002            .count();
10003        assert_eq!(
10004            add_udp_frontend_count, 2,
10005            "both cluster frontends on the udp listener must emit AddUdpFrontend"
10006        );
10007    }
10008
10009    /// The three subnet keys must travel from TOML through `Config`,
10010    /// and the per-cluster override with them. Nothing else asserts this
10011    /// plumbing: a swapped or dropped key would only surface as a cap
10012    /// that silently never fires.
10013    #[test]
10014    fn subnet_connection_limit_keys_round_trip_from_toml() {
10015        let toml_content = r#"
10016            command_socket   = "/tmp/sozu.sock"
10017            saved_state      = "./state.json"
10018            worker_count     = 1
10019
10020            max_connections_per_ip     = 10
10021            max_connections_per_subnet = 100
10022            subnet_ipv4_prefix         = 24
10023            subnet_ipv6_prefix         = 56
10024
10025            [[listeners]]
10026            protocol = "http"
10027            address  = "127.0.0.1:8080"
10028
10029            [clusters.api]
10030            protocol       = "http"
10031            load_balancing = "ROUND_ROBIN"
10032            max_connections_per_subnet = 7
10033            frontends = [
10034              { address = "127.0.0.1:8080", hostname = "example.com" }
10035            ]
10036            backends = [ { address = "10.0.0.1:8080" } ]
10037        "#;
10038        let file_config: FileConfig =
10039            toml::from_str(toml_content).expect("Could not parse TOML config");
10040        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
10041            .into_config()
10042            .expect("a config with valid subnet prefixes must load");
10043
10044        assert_eq!(
10045            config.max_connections_per_subnet, 100,
10046            "the global subnet cap must survive the TOML round trip"
10047        );
10048        assert_eq!(
10049            config.subnet_ipv4_prefix, 24,
10050            "subnet_ipv4_prefix must survive the TOML round trip"
10051        );
10052        assert_eq!(
10053            config.subnet_ipv6_prefix, 56,
10054            "subnet_ipv6_prefix must survive the TOML round trip — a swap with the IPv6 \
10055             key would leave a cap that silently never fires"
10056        );
10057        assert_eq!(
10058            config.max_connections_per_ip, 10,
10059            "the pre-existing per-IP cap must be unaffected: the two are independent"
10060        );
10061
10062        let cluster = config
10063            .clusters
10064            .get("api")
10065            .expect("the staged cluster must be present");
10066        let override_value = match cluster {
10067            ClusterConfig::Http(http) => http.max_connections_per_subnet,
10068            other => panic!("expected an HTTP cluster, got {other:?}"),
10069        };
10070        assert_eq!(
10071            override_value,
10072            Some(7),
10073            "the per-cluster subnet override must reach the cluster config"
10074        );
10075    }
10076
10077    /// The backend connection attempt budget (sozu-proxy/sozu#1800): five by
10078    /// default, set globally and per cluster from TOML, carried to the worker
10079    /// in `ServerConfig` and to the cluster in its `AddCluster`.
10080    #[test]
10081    fn max_connection_attempts_defaults_to_five_and_round_trips_from_toml() {
10082        let config_with = |global: &str, cluster: &str| -> Config {
10083            let toml_content = format!(
10084                r#"
10085            command_socket = "/tmp/sozu.sock"
10086            saved_state    = "./state.json"
10087            worker_count   = 1
10088            {global}
10089
10090            [[listeners]]
10091            protocol = "http"
10092            address  = "127.0.0.1:8080"
10093
10094            [clusters.api]
10095            protocol       = "http"
10096            load_balancing = "ROUND_ROBIN"
10097            {cluster}
10098            frontends = [
10099              {{ address = "127.0.0.1:8080", hostname = "example.com" }}
10100            ]
10101            backends = [ {{ address = "10.0.0.1:8080" }} ]
10102        "#
10103            );
10104            let file_config: FileConfig =
10105                toml::from_str(&toml_content).expect("Could not parse TOML config");
10106            ConfigBuilder::new(file_config, "/tmp/test_config.toml")
10107                .into_config()
10108                .expect("a valid attempt budget must load")
10109        };
10110        let cluster_attempts = |config: &Config| match config.clusters.get("api") {
10111            Some(ClusterConfig::Http(http)) => http.max_connection_attempts,
10112            other => panic!("expected the HTTP cluster, got {other:?}"),
10113        };
10114        let add_cluster_attempts = |config: &Config| {
10115            config
10116                .generate_config_messages()
10117                .expect("Could not generate config messages")
10118                .into_iter()
10119                .find_map(|message| match message.content.request_type {
10120                    Some(RequestType::AddCluster(cluster)) => Some(cluster.max_connection_attempts),
10121                    _ => None,
10122                })
10123                .expect("the cluster is added")
10124        };
10125
10126        let defaults = config_with("", "");
10127        assert_eq!(DEFAULT_MAX_CONNECTION_ATTEMPTS, 5);
10128        assert_eq!(defaults.max_connection_attempts, 5);
10129        assert_eq!(
10130            ServerConfig::from(&defaults).max_connection_attempts,
10131            Some(5),
10132            "the worker receives the default"
10133        );
10134        assert_eq!(
10135            cluster_attempts(&defaults),
10136            None,
10137            "a cluster inherits by default"
10138        );
10139        assert_eq!(add_cluster_attempts(&defaults), None);
10140
10141        let set = config_with("max_connection_attempts = 2", "max_connection_attempts = 7");
10142        assert_eq!(set.max_connection_attempts, 2);
10143        assert_eq!(ServerConfig::from(&set).max_connection_attempts, Some(2));
10144        assert_eq!(cluster_attempts(&set), Some(7));
10145        assert_eq!(add_cluster_attempts(&set), Some(7));
10146    }
10147
10148    /// A budget allowing no attempt, or more than the attempt counters hold,
10149    /// is rejected at load, globally and per cluster; both bounds are
10150    /// inclusive.
10151    #[test]
10152    fn an_out_of_range_max_connection_attempts_is_rejected() {
10153        let load = |global: &str, cluster: &str| {
10154            let toml_content = format!(
10155                r#"
10156            command_socket = "/tmp/sozu.sock"
10157            saved_state    = "./state.json"
10158            worker_count   = 1
10159            {global}
10160
10161            [[listeners]]
10162            protocol = "http"
10163            address  = "127.0.0.1:8080"
10164
10165            [clusters.api]
10166            protocol       = "http"
10167            load_balancing = "ROUND_ROBIN"
10168            {cluster}
10169            frontends = [
10170              {{ address = "127.0.0.1:8080", hostname = "example.com" }}
10171            ]
10172            backends = [ {{ address = "10.0.0.1:8080" }} ]
10173        "#
10174            );
10175            let file_config: FileConfig =
10176                toml::from_str(&toml_content).expect("Could not parse TOML config");
10177            ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config()
10178        };
10179        for value in ["0", "256"] {
10180            let global = format!("max_connection_attempts = {value}");
10181            assert!(
10182                matches!(
10183                    load(&global, ""),
10184                    Err(ConfigError::InvalidMaxConnectionAttempts {
10185                        cluster_id: None,
10186                        ..
10187                    })
10188                ),
10189                "a global budget of {value} must be rejected"
10190            );
10191            assert!(
10192                matches!(
10193                    load("", &global),
10194                    Err(ConfigError::InvalidMaxConnectionAttempts {
10195                        cluster_id: Some(_),
10196                        ..
10197                    })
10198                ),
10199                "a cluster budget of {value} must be rejected"
10200            );
10201        }
10202        for value in ["1", "255"] {
10203            let budget = format!("max_connection_attempts = {value}");
10204            assert!(load(&budget, &budget).is_ok(), "{value} is in range");
10205        }
10206    }
10207
10208    /// An out-of-range prefix is REJECTED, never silently clamped. A
10209    /// narrowed mask would enforce a policy other than the one written
10210    /// in the file, and nothing in the config would say so.
10211    #[test]
10212    fn an_out_of_range_subnet_prefix_is_rejected_not_clamped() {
10213        let with_prefixes = |v4: &str, v6: &str| -> String {
10214            format!(
10215                r#"
10216            command_socket = "/tmp/sozu.sock"
10217            saved_state    = "./state.json"
10218            worker_count   = 1
10219
10220            max_connections_per_subnet = 100
10221            subnet_ipv4_prefix         = {v4}
10222            subnet_ipv6_prefix         = {v6}
10223
10224            [[listeners]]
10225            protocol = "http"
10226            address  = "127.0.0.1:8080"
10227        "#
10228            )
10229        };
10230
10231        for (v4, v6, key) in [
10232            ("33", "56", "subnet_ipv4_prefix"),
10233            ("24", "129", "subnet_ipv6_prefix"),
10234        ] {
10235            let file_config: FileConfig =
10236                toml::from_str(&with_prefixes(v4, v6)).expect("Could not parse TOML config");
10237            let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
10238            match result {
10239                Err(ConfigError::InvalidSubnetPrefix { key: reported, .. }) => {
10240                    assert_eq!(
10241                        reported, key,
10242                        "the rejection must name the key that is out of range"
10243                    );
10244                }
10245                other => panic!("{key} out of range must be rejected, got {other:?}"),
10246            }
10247        }
10248
10249        // Positive space: both bounds are INCLUSIVE, so the widest legal
10250        // pair must still load.
10251        let file_config: FileConfig =
10252            toml::from_str(&with_prefixes("32", "128")).expect("Could not parse TOML config");
10253        assert!(
10254            ConfigBuilder::new(file_config, "/tmp/test_config.toml")
10255                .into_config()
10256                .is_ok(),
10257            "the full-width prefixes are legal and are the defaults — they must load"
10258        );
10259    }
10260
10261    #[test]
10262    fn old_state_file_without_client_auth_fields_deserializes() {
10263        // A state file written before mTLS landed carries no `client_ca_*`
10264        // fields. `#[serde(default)]` (via build.rs) must let it round-trip,
10265        // defaulting the new repeated fields to empty rather than erroring on a
10266        // missing field.
10267        let address = SocketAddress::new_v4(127, 0, 0, 1, 8443);
10268        let config = ListenerBuilder::new_https(address)
10269            .to_tls(None)
10270            .expect("default HTTPS listener config");
10271
10272        let mut json: serde_json::Value =
10273            serde_json::to_value(&config).expect("serialize HttpsListenerConfig");
10274        let obj = json.as_object_mut().expect("object");
10275        obj.remove("client_ca_certificates");
10276        obj.remove("client_ca_crls");
10277        obj.remove("client_auth");
10278
10279        let restored: HttpsListenerConfig =
10280            serde_json::from_value(json).expect("old state file must deserialize");
10281        assert!(restored.client_ca_certificates.is_empty());
10282        assert!(restored.client_ca_crls.is_empty());
10283        assert_eq!(restored.client_auth, None);
10284    }
10285
10286    #[test]
10287    fn client_auth_fields_rejected_on_non_https_listener() {
10288        // A TCP listener carrying client_auth must be rejected, not silently
10289        // started unauthenticated.
10290        let address = SocketAddress::new_v4(127, 0, 0, 1, 9000);
10291        let mut tcp = ListenerBuilder::new_tcp(address);
10292        tcp.client_auth = Some(ClientAuthConfig::Required);
10293        assert!(matches!(
10294            tcp.to_tcp(None),
10295            Err(ConfigError::ClientAuthOnNonHttps(_))
10296        ));
10297
10298        // A CA bundle without an explicit mode is equally a misconfiguration.
10299        let mut http = ListenerBuilder::new_http(address);
10300        http.client_ca_certificates = Some(vec!["ca.pem".to_owned()]);
10301        assert!(matches!(
10302            http.to_http(None),
10303            Err(ConfigError::ClientAuthOnNonHttps(_))
10304        ));
10305    }
10306
10307    #[test]
10308    fn none_mode_ignores_stale_ca_paths() {
10309        // With client auth disabled, an unreadable CA/CRL path must not block
10310        // the HTTPS listener from materializing.
10311        let address = SocketAddress::new_v4(127, 0, 0, 1, 9443);
10312        let mut https = ListenerBuilder::new_https(address);
10313        https.client_auth = Some(ClientAuthConfig::None);
10314        https.client_ca_certificates = Some(vec!["/nonexistent/ca.pem".to_owned()]);
10315        https.client_ca_crls = Some(vec!["/nonexistent/crl.pem".to_owned()]);
10316        let config = https.to_tls(None).expect("NONE mode must ignore CA paths");
10317        assert!(config.client_ca_certificates.is_empty());
10318        assert!(config.client_ca_crls.is_empty());
10319    }
10320
10321    /// The configuration file names the CA and CRL files; the main process
10322    /// reads them at config-load and sends their contents to the workers, on
10323    /// the verb a worker that predates mutual TLS cannot decode.
10324    #[test]
10325    fn client_auth_listener_inlines_its_ca_and_crl_files_into_the_listener_request() {
10326        let asset =
10327            |name: &str| format!("{}/../lib/assets/mtls/{name}", env!("CARGO_MANIFEST_DIR"));
10328        let (ca_path, crl_path) = (asset("ca-cert.pem"), asset("crl-revoked.pem"));
10329        let toml_content = format!(
10330            r#"
10331            command_socket = "/tmp/sozu.sock"
10332            saved_state    = "./state.json"
10333            worker_count   = 1
10334
10335            [[listeners]]
10336            protocol = "https"
10337            address  = "127.0.0.1:8443"
10338            client_auth = "required"
10339            client_ca_certificates = ["{ca_path}"]
10340            client_ca_crls = ["{crl_path}"]
10341        "#
10342        );
10343        let file_config: FileConfig =
10344            toml::from_str(&toml_content).expect("Could not parse TOML config");
10345        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
10346            .into_config()
10347            .expect("a client auth listener with readable CA and CRL files must load");
10348
10349        let listeners: Vec<HttpsListenerConfig> = config
10350            .generate_config_messages()
10351            .expect("the config must produce its worker requests")
10352            .into_iter()
10353            .filter_map(|request| match request.content.request_type {
10354                Some(RequestType::AddHttpsListenerWithClientAuth(listener)) => Some(listener),
10355                Some(RequestType::AddHttpsListener(_)) => {
10356                    panic!("a client auth listener must not go out as AddHttpsListener")
10357                }
10358                _ => None,
10359            })
10360            .collect();
10361        assert_eq!(listeners.len(), 1, "one HTTPS listener is configured");
10362        let listener = &listeners[0];
10363        assert_eq!(
10364            listener.client_auth,
10365            Some(ClientAuthMode::ClientAuthRequired as i32)
10366        );
10367        assert_eq!(
10368            listener.client_ca_certificates,
10369            vec![std::fs::read_to_string(&ca_path).expect("read the CA fixture")],
10370            "the CA file content, not its path, must reach the worker"
10371        );
10372        assert_eq!(
10373            listener.client_ca_crls,
10374            vec![std::fs::read_to_string(&crl_path).expect("read the CRL fixture")],
10375            "the CRL file content, not its path, must reach the worker"
10376        );
10377        assert!(
10378            listener.client_ca_crls[0].contains("BEGIN X509 CRL"),
10379            "the inlined CRL is the PEM body"
10380        );
10381    }
10382
10383    #[test]
10384    fn client_auth_listener_with_an_unreadable_crl_file_does_not_load() {
10385        // A CRL the operator configured but Sozu cannot read would silently
10386        // disable the revocation they asked for.
10387        let address = SocketAddress::new_v4(127, 0, 0, 1, 9443);
10388        let mut https = ListenerBuilder::new_https(address);
10389        https.client_auth = Some(ClientAuthConfig::Required);
10390        https.client_ca_certificates = Some(vec![format!(
10391            "{}/../lib/assets/mtls/ca-cert.pem",
10392            env!("CARGO_MANIFEST_DIR")
10393        )]);
10394        https.client_ca_crls = Some(vec!["/nonexistent/crl.pem".to_owned()]);
10395        assert!(
10396            https.to_tls(None).is_err(),
10397            "an unreadable CRL file must fail the listener, not drop the revocation check"
10398        );
10399    }
10400
10401    #[test]
10402    fn client_auth_files_without_a_mode_are_rejected() {
10403        // The mode defaults to `none`, which ignores these files: a listener
10404        // naming a CA or a CRL with no mode would start unauthenticated.
10405        let address = SocketAddress::new_v4(127, 0, 0, 1, 9443);
10406        for (cas, crls) in [
10407            (Some(vec!["/etc/sozu/client-ca.pem".to_owned()]), None),
10408            (None, Some(vec!["/etc/sozu/client-ca.crl.pem".to_owned()])),
10409        ] {
10410            let mut https = ListenerBuilder::new_https(address);
10411            https.client_ca_certificates = cas;
10412            https.client_ca_crls = crls;
10413            assert!(matches!(
10414                https.to_tls(None),
10415                Err(ConfigError::ClientAuthModeMissing(_))
10416            ));
10417        }
10418        // Positive space: an explicit `none` keeps the files unused (and is
10419        // only warned about), and no file at all needs no mode.
10420        let mut explicit_none = ListenerBuilder::new_https(address);
10421        explicit_none.client_auth = Some(ClientAuthConfig::None);
10422        explicit_none.client_ca_certificates = Some(vec!["/nonexistent/ca.pem".to_owned()]);
10423        assert!(explicit_none.to_tls(None).is_ok());
10424        assert!(ListenerBuilder::new_https(address).to_tls(None).is_ok());
10425    }
10426
10427    #[test]
10428    fn client_auth_config_parses_documented_lowercase_names() {
10429        // The documented TOML values (`none`/`optional`/`required`) must parse,
10430        // not just the protobuf enum's SCREAMING_SNAKE_CASE.
10431        assert_eq!(
10432            serde_json::from_str::<ClientAuthConfig>("\"required\"").unwrap(),
10433            ClientAuthConfig::Required
10434        );
10435        assert_eq!(
10436            serde_json::from_str::<ClientAuthConfig>("\"optional\"").unwrap(),
10437            ClientAuthConfig::Optional
10438        );
10439        assert_eq!(
10440            serde_json::from_str::<ClientAuthConfig>("\"none\"").unwrap(),
10441            ClientAuthConfig::None
10442        );
10443        assert_eq!(
10444            ClientAuthMode::from(ClientAuthConfig::Required),
10445            ClientAuthMode::ClientAuthRequired
10446        );
10447    }
10448}