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} HTTPS listener(s) advertise H2 ALPN. The H2 mux deadlocks \
537         on full-size frames with smaller buffers. Raise buffer_size to >= {minimum} \
538         or remove \"h2\" from those listeners' alpn_protocols."
539    )]
540    BufferSizeTooSmallForH2 {
541        buffer_size: u64,
542        minimum: u64,
543        listeners: usize,
544    },
545    /// `command_buffer_size` is greater than `max_command_buffer_size`. The command
546    /// channel (`command/src/channel.rs`) allocates its initial buffer at
547    /// `command_buffer_size` and must never let it exceed `max_command_buffer_size` —
548    /// every growth/shrink path on that channel reasons against that ceiling. Rejected
549    /// at load rather than silently clamped, so the operator sees which of the two
550    /// keys to fix instead of the mistake being rewritten out from under them
551    /// (sozu-proxy/sozu#1416).
552    #[error(
553        "command_buffer_size = {command_buffer_size} exceeds max_command_buffer_size = \
554         {max_command_buffer_size}: lower command_buffer_size to <= {max_command_buffer_size} \
555         or raise max_command_buffer_size to >= {command_buffer_size}"
556    )]
557    CommandBufferSizeExceedsMax {
558        command_buffer_size: u64,
559        max_command_buffer_size: u64,
560    },
561    /// An H2 listener knob was set below the minimum the runtime can honour.
562    /// Every flood-detection threshold needs `>= 1`: `check_flood`
563    /// (`lib/src/protocol/mux/h2_flood_detector.rs`) compares
564    /// `count > threshold`, so `0` does not disable the check, it makes the
565    /// first event the counter sees a violation. `h2_stream_shrink_ratio`
566    /// needs `>= 2` for the same reason its runtime `.max(2)` exists.
567    ///
568    /// What that costs depends on which counter the key guards, and the error
569    /// text says so rather than promising the worst case for all of them:
570    ///
571    /// - `h2_max_header_list_size` and `h2_max_header_fields` are *also* the
572    ///   HPACK decode budget `lib/src/protocol/mux/pkawa.rs`'s
573    ///   `decode_headers_with_budget` enforces. At `0` the first header field
574    ///   of every block already exceeds it (name + value + the 32-octet RFC
575    ///   9113 §6.5.2 overhead), so every request on the listener is refused
576    ///   with `ENHANCE_YOUR_CALM`. Immediate and universal. The frame it
577    ///   arrives in depends on the request: a header block that fits one
578    ///   HEADERS frame is refused per stream (`pkawa.rs`'s
579    ///   `Err((EnhanceYourCalm, false))` → `reset_stream`), while one that
580    ///   spans CONTINUATION frames takes the whole connection down —
581    ///   `record_continuation_frame` lifts `accumulated_header_size` above the
582    ///   zero threshold and `check_flood_or_return!`
583    ///   (`lib/src/protocol/mux/h2.rs`) escalates to
584    ///   `GOAWAY(ENHANCE_YOUR_CALM)` before the explicit size test below it.
585    ///   `h2_max_header_table_size` is NOT in this class: `0` only zeroes the
586    ///   HPACK encoder's dynamic table.
587    /// - The per-window and lifetime frame counters only move when a client
588    ///   sends the frame they count. `h2_max_rst_stream_per_window = 0` is
589    ///   harmless to a connection that never sends RST_STREAM — `0 > 0` is
590    ///   false — and fatal to the first one that does.
591    /// - `h2_max_concurrent_streams` and `h2_stream_shrink_ratio` are not
592    ///   flood counters at all: `H2ConnectionConfig::new`
593    ///   (`lib/src/protocol/mux/h2.rs`) promotes an out-of-range value to the
594    ///   floor behind a `warn!`, which is the silent rewrite this error
595    ///   exists to replace.
596    ///
597    /// Rejected at config load rather than silently clamped, so the operator
598    /// sees which key to fix instead of the mistake being rewritten out from
599    /// under them. `UpdateHttp(s)Listener` already refuses the same values via
600    /// `validate_h2_flood_knobs_http`/`_https` (`command/src/state.rs`); this
601    /// is the matching gate on the config-file / `sozu ctl add listener` path
602    /// (sozu-proxy/sozu#1418).
603    #[error(
604        "listener {address}: {key} = {value} is below the minimum of {minimum} the runtime can \
605         honour. Below the minimum the value does not relax the limit, it breaks it: the H2 flood \
606         detector compares count > threshold, so 0 is already exceeded the first time the counter \
607         it guards leaves zero. How far that reaches depends on the key — \
608         h2_max_header_list_size and h2_max_header_fields are also the HPACK decode budget, so 0 \
609         refuses every request on this listener with ENHANCE_YOUR_CALM (as a stream reset, or as \
610         a connection GOAWAY once a header block spans CONTINUATION frames), while a per-window \
611         or lifetime frame counter only trips once a client sends the frame it counts, and \
612         h2_max_concurrent_streams / h2_stream_shrink_ratio are instead promoted to their floor \
613         behind a warning. Raise {key} to >= {minimum}, or remove it to keep the default."
614    )]
615    H2ThresholdBelowMinimum {
616        address: String,
617        key: &'static str,
618        value: u64,
619        minimum: u64,
620    },
621    /// `redirect = "<value>"` on a frontend used a value the parser doesn't
622    /// recognise. Accepted values are `forward`, `permanent`, `unauthorized`,
623    /// `silent_close` (case-insensitive).
624    #[error(
625        "invalid redirect policy '{0}'. Valid values: \"forward\", \"permanent\", \"unauthorized\", \"silent_close\""
626    )]
627    InvalidRedirectPolicy(String),
628    /// `redirect_scheme = "<value>"` on a frontend used a value the parser
629    /// doesn't recognise. Accepted values are `use-same`, `use-http`,
630    /// `use-https` (case-insensitive).
631    #[error(
632        "invalid redirect scheme '{0}'. Valid values: \"use-same\", \"use-http\", \"use-https\""
633    )]
634    InvalidRedirectScheme(String),
635    /// A `[[clusters.<id>.frontends.headers]]` entry carried an unknown
636    /// `position` value. Accepted values are `request`, `response`, `both`
637    /// (case-insensitive).
638    #[error(
639        "invalid header position '{position}' at headers[{index}]. Valid values: \"request\", \"response\", \"both\""
640    )]
641    InvalidHeaderPosition { index: usize, position: String },
642    /// A `[[clusters.<id>.frontends.headers]]` entry contains a forbidden
643    /// byte (NUL, CR, LF, or another C0 control) in its key or value.
644    /// Accepting these would produce HTTP request/response splitting on
645    /// the wire (CWE-113) — the worker's H2 emission path filters them
646    /// at runtime, but the H1 path serialises raw, so we reject at
647    /// config-load time as a defense in depth.
648    #[error(
649        "invalid header bytes in {field} at headers[{index}]: control characters \
650         (NUL / CR / LF / other C0) are forbidden in header keys and values"
651    )]
652    InvalidHeaderBytes { index: usize, field: &'static str },
653    /// A `header_predicates` entry on a frontend is not a usable routing
654    /// condition (sozu-proxy/sozu#1253); `reason` names what is wrong. The
655    /// regex grammar of a `regex` predicate is checked later, by the
656    /// router, exactly as a `path_type = "regex"` path is.
657    #[error("invalid header predicate at header_predicates[{index}]: {reason}")]
658    InvalidHeaderPredicate { index: usize, reason: &'static str },
659    /// An `[hsts]` block populated `max_age`, `include_subdomains`, or
660    /// `preload` but did not set `enabled`. The TOML representation requires
661    /// `enabled` to be present whenever the block is — that single field
662    /// disambiguates "preserve current" / "explicit disable" / "enable" on
663    /// hot-reconfig partial updates.
664    #[error("invalid HSTS config at {0}: `enabled` is required when an [hsts] block is present")]
665    HstsEnabledRequired(String),
666    /// An `[hsts]` block on an HTTP-only listener or frontend. RFC 6797
667    /// §7.2 forbids emitting `Strict-Transport-Security` over plaintext
668    /// HTTP; sozu rejects the configuration at load time so the
669    /// non-conformant policy never ships to a worker.
670    #[error(
671        "invalid HSTS config at {0}: HSTS is only valid on HTTPS listeners and frontends \
672         (RFC 6797 §7.2 forbids the header over plaintext HTTP)"
673    )]
674    HstsOnPlainHttp(String),
675    /// A client-certificate-authentication (mTLS) field was set on a non-HTTPS
676    /// listener. Client auth is a TLS-termination control; HTTP/TCP/UDP
677    /// listeners discard it, so sozu rejects it at load time rather than
678    /// silently starting an unauthenticated listener.
679    #[error(
680        "invalid client auth config at {0}: client_auth / client_ca_certificates / \
681         client_ca_crls are only valid on HTTPS listeners"
682    )]
683    ClientAuthOnNonHttps(String),
684    /// An HTTPS listener names client CA or CRL files but no `client_auth`
685    /// mode. The mode defaults to `none`, which ignores those files, so the
686    /// listener would start without the client authentication they suggest.
687    #[error(
688        "invalid client auth config at {0}: client_ca_certificates / client_ca_crls are set \
689         but client_auth is not; set client_auth to \"optional\" or \"required\", or to \
690         \"none\" to keep them unused"
691    )]
692    ClientAuthModeMissing(String),
693    /// A TCP frontend's `hostname` (mapped to the wire `sni` field) is
694    /// neither an exact hostname nor a single leading `*.` wildcard label
695    /// (sozu-proxy/sozu#1279). Rejects `*.*.example.com`, an embedded `*`
696    /// anywhere but the leading label, an empty label, and any `/` — never
697    /// valid in a hostname, and a leftmost `/.../` label would otherwise be
698    /// inserted into the `pattern_trie` route table as a REGEX segment.
699    #[error(
700        "invalid SNI pattern '{sni}' for a TCP frontend: expected an exact hostname or a \
701         single leading \"*.\" wildcard label (e.g. \"example.com\" or \"*.example.com\"); \
702         '/' and non-leading '*' are rejected"
703    )]
704    InvalidSniPattern { sni: String },
705    /// A TCP frontend's SNI pattern contains non-ASCII characters. On-wire
706    /// SNI is always an ASCII A-label (RFC 6066 §3 / IDNA), so a Unicode
707    /// U-label in the config would load fine but never match any
708    /// ClientHello — a silent routing failure. Rejected loudly until IDNA
709    /// normalization is supported at config-load; the operator must write
710    /// the punycode A-label form instead.
711    #[error(
712        "non-ASCII SNI pattern '{sni}' for a TCP frontend: on-wire SNI is always an ASCII \
713         A-label (RFC 6066), so this pattern would never match a ClientHello. Write the \
714         punycode A-label form instead (e.g. \"xn--mnchen-3ya.example\" for \
715         \"münchen.example\")"
716    )]
717    NonAsciiSniPattern { sni: String },
718    /// A TCP frontend set `alpn` but left `hostname` (mapped to the wire
719    /// `sni` field) unset. An ALPN matcher only ever gets consulted from
720    /// within the SNI-scoped preread route table; a frontend with no `sni`
721    /// installs the worker's raw no-SNI catch-all path instead
722    /// (`TcpListener::cluster_id`), which never looks at `alpn` at all --
723    /// the configured protocol list would silently never be enforced.
724    /// Reject at config-load rather than mis-routing every connection
725    /// through unconditionally.
726    #[error(
727        "TCP frontend {address} sets alpn but no hostname (sni): alpn only matches within an \
728         SNI-scoped preread, so a frontend without hostname would silently ignore its alpn list. \
729         Set hostname or drop alpn."
730    )]
731    AlpnWithoutSni { address: SocketAddr },
732    /// Two TCP frontends on the same `(address, sni)` advertise an
733    /// overlapping ALPN protocol. Routing on a listener must be
734    /// deterministic: if both frontends could match the same ClientHello,
735    /// which cluster receives the connection would depend on iteration
736    /// order rather than configuration.
737    #[error(
738        "TCP frontends on {address} with sni {sni:?} both match ALPN protocol '{protocol}': \
739         ALPN matchers for the same (address, sni) must not overlap"
740    )]
741    TcpFrontendAlpnOverlap {
742        address: SocketAddr,
743        sni: Option<String>,
744        protocol: String,
745    },
746    /// More than one TCP frontend on the same `(address, sni)` left `alpn`
747    /// empty. An empty `alpn` is the catch-all match for that `sni`; two
748    /// catch-alls on the same `(address, sni)` are as ambiguous as two
749    /// frontends sharing an explicit protocol.
750    #[error(
751        "more than one TCP frontend on {address} with sni {sni:?} leaves alpn empty (the \
752         catch-all match): at most one frontend per (address, sni) may omit alpn"
753    )]
754    TcpFrontendMultipleAlpnCatchAll {
755        address: SocketAddr,
756        sni: Option<String>,
757    },
758    /// A TCP listener address is targeted by both a no-SNI frontend and at
759    /// least one SNI-scoped frontend. An SNI-enabled listener prereads the
760    /// ClientHello before choosing a backend; a raw-TCP fallback frontend
761    /// with no SNI to match against would be unreachable for any client
762    /// that doesn't send SNI, and ambiguous for any that does, so the two
763    /// shapes cannot share a listener.
764    #[error(
765        "TCP listener {address} is targeted by both a no-SNI frontend and at least one \
766         SNI-scoped frontend: an SNI-enabled listener must not also have a raw-TCP fallback \
767         frontend on the same address"
768    )]
769    TcpListenerMixesSniAndNoSni { address: SocketAddr },
770    /// `sni_preread_timeout` on a TCP listener exceeds that listener's
771    /// `front_timeout`. The preread phase is bounded by the frontend's own
772    /// inactivity timeout, so a preread budget longer than the timeout that
773    /// would kill the connection anyway can never fully elapse — it is
774    /// either a config mistake or silently dead configuration.
775    #[error(
776        "sni_preread_timeout = {sni_preread_timeout}s on TCP listener {address} exceeds its \
777         front_timeout = {front_timeout}s: the preread phase cannot outlive the frontend \
778         inactivity timeout that would already have closed the connection"
779    )]
780    SniPrereadTimeoutExceedsFrontTimeout {
781        address: SocketAddr,
782        sni_preread_timeout: u32,
783        front_timeout: u32,
784    },
785    /// `sni_preread_max_bytes` on a TCP listener targeted by at least one
786    /// SNI frontend exceeds the global `buffer_size`. The preread buffer is
787    /// carved out of the same per-session buffer the proxy uses for
788    /// relaying, so a preread ceiling above `buffer_size` can never be
789    /// reached in practice and signals a misconfiguration.
790    #[error(
791        "sni_preread_max_bytes = {sni_preread_max_bytes} on TCP listener {address} exceeds \
792         buffer_size = {buffer_size}: raise buffer_size to >= {sni_preread_max_bytes} or lower \
793         sni_preread_max_bytes"
794    )]
795    SniPrereadMaxBytesExceedsBufferSize {
796        address: SocketAddr,
797        sni_preread_max_bytes: u32,
798        buffer_size: u64,
799    },
800    /// `sni_preread_max_bytes` on a TCP listener targeted by at least one
801    /// SNI frontend is below [`MIN_SNI_PREREAD_MAX_BYTES`]. `0` in
802    /// particular makes the preread shell issue zero-length reads that can
803    /// never make progress -- the session spins until the event-loop
804    /// iteration guard (`MAX_LOOP_ITERATIONS`) trips instead of ever
805    /// completing a preread decision. The floor is a full TLS record
806    /// header (1-byte `ContentType` + 2-byte `ProtocolVersion` + 2-byte
807    /// length, RFC 8446 §5.1): below that, the shell cannot even learn how
808    /// many more bytes to wait for.
809    #[error(
810        "sni_preread_max_bytes = {sni_preread_max_bytes} on TCP listener {address} is below the \
811         minimum of {minimum} bytes (a full TLS record header): the preread shell could never \
812         read enough bytes to make progress. Raise sni_preread_max_bytes to >= {minimum}"
813    )]
814    SniPrereadMaxBytesTooSmall {
815        address: SocketAddr,
816        sni_preread_max_bytes: u32,
817        minimum: u32,
818    },
819    /// A backend `address` of the configuration file is neither a socket
820    /// address nor a `host:port` that resolves to at least one address
821    /// (sozu-proxy/sozu#347). Names are resolved once, when the file is
822    /// loaded at startup or by `sozu reload --file`.
823    #[error(
824        "cluster {cluster_id}: cannot resolve backend address {address:?}: {reason}. \
825         Expected an IP:port socket address or a resolvable host:port"
826    )]
827    BackendAddressResolution {
828        cluster_id: String,
829        address: String,
830        reason: String,
831    },
832}
833
834/// Config-facing mutual-TLS (client certificate authentication) mode, parsed
835/// from the `client_auth` key of an HTTPS listener's TOML section. Serializes as
836/// lowercase (`"none"` / `"optional"` / `"required"`) so operators write the
837/// documented values rather than the protobuf enum's `SCREAMING_SNAKE_CASE`.
838/// Maps to the wire [`ClientAuthMode`] in [`ListenerBuilder::to_tls`].
839#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
840#[serde(rename_all = "lowercase")]
841pub enum ClientAuthConfig {
842    #[default]
843    None,
844    Optional,
845    Required,
846}
847
848impl From<ClientAuthConfig> for ClientAuthMode {
849    fn from(value: ClientAuthConfig) -> Self {
850        match value {
851            ClientAuthConfig::None => ClientAuthMode::ClientAuthNone,
852            ClientAuthConfig::Optional => ClientAuthMode::ClientAuthOptional,
853            ClientAuthConfig::Required => ClientAuthMode::ClientAuthRequired,
854        }
855    }
856}
857
858/// An HTTP, HTTPS or TCP listener as parsed from the `Listeners` section in the toml
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
860#[serde(deny_unknown_fields)]
861pub struct ListenerBuilder {
862    pub address: SocketAddr,
863    /// Network interface the listening socket is bound to with
864    /// `SO_BINDTODEVICE` (e.g. `"wg0"`), for an address that is not known
865    /// ahead. Linux only: refused at config load on any other platform. Part
866    /// of the listener identity, so two listeners may share an `address` on
867    /// different interfaces.
868    pub interface: Option<String>,
869    pub protocol: Option<ListenerProtocol>,
870    pub public_address: Option<SocketAddr>,
871    pub answer_301: Option<String>,
872    pub answer_400: Option<String>,
873    pub answer_401: Option<String>,
874    pub answer_404: Option<String>,
875    pub answer_408: Option<String>,
876    pub answer_413: Option<String>,
877    /// RFC 9110 §15.5.20 — returned when the request's `:authority` / `Host`
878    /// host does not match the TLS SNI negotiated for this connection.
879    pub answer_421: Option<String>,
880    pub answer_502: Option<String>,
881    pub answer_503: Option<String>,
882    pub answer_504: Option<String>,
883    pub answer_507: Option<String>,
884    /// RFC 6585 §4 — emitted when a request would have reached a backend
885    /// but the per-(cluster, source-IP) connection limit is full. Honoured
886    /// like the other deprecated `answer_NNN` fields: copies into the
887    /// listener-level `answers` map at the matching status.
888    pub answer_429: Option<String>,
889    pub tls_versions: Option<Vec<TlsVersion>>,
890    pub cipher_list: Option<Vec<String>>,
891    pub cipher_suites: Option<Vec<String>>,
892    pub groups_list: Option<Vec<String>>,
893    pub expect_proxy: Option<bool>,
894    #[serde(default = "default_sticky_name")]
895    pub sticky_name: String,
896    pub certificate: Option<String>,
897    pub certificate_chain: Option<String>,
898    pub key: Option<String>,
899    /// maximum time of inactivity for a frontend socket
900    pub front_timeout: Option<u32>,
901    /// maximum time of inactivity for a backend socket
902    pub back_timeout: Option<u32>,
903    /// maximum time to connect to a backend server
904    pub connect_timeout: Option<u32>,
905    /// maximum time to receive a request since the connection started
906    pub request_timeout: Option<u32>,
907    /// A [Config] to pull defaults from
908    pub config: Option<Config>,
909    /// Number of TLS 1.3 tickets to send to a client when establishing a connection.
910    /// The ticket allow the client to resume a session. This protects the client
911    /// agains session tracking. Defaults to 4.
912    pub send_tls13_tickets: Option<u64>,
913    /// ALPN protocols to advertise during TLS handshake, in order of preference.
914    /// Valid values: "h2", "http/1.1". Defaults to ["h2", "http/1.1"].
915    pub alpn_protocols: Option<Vec<String>>,
916    /// H2 flood detection: max RST_STREAM frames per second window (CVE-2023-44487, CVE-2019-9514)
917    pub h2_max_rst_stream_per_window: Option<u32>,
918    /// H2 flood detection: max PING frames per second window (CVE-2019-9512)
919    pub h2_max_ping_per_window: Option<u32>,
920    /// H2 flood detection: max SETTINGS frames per second window (CVE-2019-9515)
921    pub h2_max_settings_per_window: Option<u32>,
922    /// H2 flood detection: max empty DATA frames per second window (CVE-2019-9518)
923    pub h2_max_empty_data_per_window: Option<u32>,
924    /// H2 flood detection: max unsolicited connection-level (stream 0)
925    /// WINDOW_UPDATE frames per sliding window — each DATA frame the proxy
926    /// sends entitles the peer to two that are not counted. Caps non-zero
927    /// stream-0 WINDOW_UPDATE floods that would otherwise stay under the
928    /// generic glitch counter. Default: 2000.
929    pub h2_max_window_update_stream0_per_window: Option<u32>,
930    /// Name of the correlation header Sozu injects into every request and
931    /// response. Default: `Sozu-Id`. Operators can rebrand (e.g. `X-Edge-Id`)
932    /// without touching code.
933    pub sozu_id_header: Option<String>,
934    /// H2 flood detection: max CONTINUATION frames per header block (CVE-2024-27316)
935    pub h2_max_continuation_frames: Option<u32>,
936    /// H2 flood detection: max accumulated protocol anomalies before
937    /// ENHANCE_YOUR_CALM. Default: 2000.
938    pub h2_max_glitch_count: Option<u32>,
939    /// H2 connection-level receive window size in bytes (RFC 9113 §6.9.2). Default: 16777216 (16 MiB).
940    pub h2_initial_connection_window: Option<u32>,
941    /// Maximum concurrent H2 streams (SETTINGS_MAX_CONCURRENT_STREAMS). Default: 100.
942    pub h2_max_concurrent_streams: Option<u32>,
943    /// Shrink threshold ratio for recycled stream slots. Default: 2.
944    pub h2_stream_shrink_ratio: Option<u32>,
945    /// H2 flood detection: floor of the connection-lifetime cap on RST_STREAM
946    /// frames received (CVE-2023-44487); trips once the count exceeds this
947    /// value and the resets received after a response started or on a closed
948    /// stream exceed the streams a backend answered. Default: 200000.
949    pub h2_max_rst_stream_lifetime: Option<u64>,
950    /// H2 flood detection: floor of the cap on "abusive" (pre-response-start)
951    /// RST_STREAM frames (Rapid Reset signature, CVE-2023-44487); trips once
952    /// the count exceeds this value and more than half of the
953    /// backend-routed streams were reset before their response.
954    /// Default: 1000.
955    pub h2_max_rst_stream_abusive_lifetime: Option<u64>,
956    /// H2 flood detection: floor of the cap on **server-emitted** RST_STREAM
957    /// frames the peer provoked (CVE-2025-8671 "MadeYouReset"); trips once the
958    /// count exceeds this value and more than half of the
959    /// backend-routed streams were reset before their response. Resets
960    /// Sōzu decides on its own and `NoError` resets are not counted.
961    /// Default: 10000.
962    pub h2_max_rst_stream_emitted_lifetime: Option<u64>,
963    /// H2 flood detection: maximum accumulated HPACK-decoded header list
964    /// size per request (SETTINGS_MAX_HEADER_LIST_SIZE, RFC 9113 §6.5.2).
965    /// Default: 65536.
966    pub h2_max_header_list_size: Option<u32>,
967    /// Maximum HPACK dynamic table size (SETTINGS_HEADER_TABLE_SIZE) accepted
968    /// from the peer. Caps the value the peer advertises in SETTINGS frames to
969    /// prevent unbounded HPACK encoder memory growth. Default: 65536.
970    pub h2_max_header_table_size: Option<u32>,
971    /// Maximum number of materialized header fields per request — HPACK fields
972    /// plus expanded cookie crumbs (RFC 9113 §8.2.3). Bounds the HPACK
973    /// indexed-reference header bomb. Default: 128.
974    pub h2_max_header_fields: Option<u32>,
975    /// H2 flood detection: share, in percent, of the pre-response RST_STREAM
976    /// cap's floor (`h2_max_rst_stream_abusive_lifetime`) at which a
977    /// connection refuses new client streams with `REFUSED_STREAM`
978    /// (retryable, RFC 9113 §8.7) and keeps serving its open streams. `0`
979    /// disables it; `100` or more never engages it before the cap.
980    /// Default: 50.
981    pub h2_stream_refusal_percent: Option<u32>,
982    /// Per-stream idle timeout, in seconds. An open H2 stream that makes no
983    /// forward progress for this duration is cancelled (RST_STREAM / CANCEL)
984    /// to defend against slow-multiplex Slowloris. Unset, it inherits
985    /// `back_timeout`, floored at 30 (so 30 with the default `back_timeout`);
986    /// an explicit value wins, `0` meaning 1.
987    pub h2_stream_idle_timeout_seconds: Option<u32>,
988    /// Maximum wall-clock seconds to wait for in-flight H2 streams after
989    /// `GOAWAY(NO_ERROR)` has been sent during soft-stop. Once the deadline
990    /// elapses the connection is forcibly closed with a final GOAWAY. Set to
991    /// `0` to wait for streams to finish (no forced close). Default: 5.
992    pub h2_graceful_shutdown_deadline_seconds: Option<u32>,
993    /// When true, every HTTP request served on this listener must have its
994    /// `:authority` / `Host` host exact-match the TLS SNI negotiated at
995    /// handshake (CWE-346 / CWE-444). Applies to HTTPS listeners only;
996    /// plaintext HTTP listeners never have an SNI to compare against.
997    /// Default: true.
998    pub strict_sni_binding: Option<bool>,
999    /// When true, this HTTPS listener only accepts HTTP/2 connections;
1000    /// clients that do not negotiate `h2` via TLS ALPN (including those
1001    /// that omit ALPN entirely) are dropped at handshake instead of
1002    /// silently downgrading to HTTP/1.1. Default: false.
1003    pub disable_http11: Option<bool>,
1004    /// When true, any client-supplied `X-Real-IP` header is stripped from
1005    /// requests before forwarding (anti-spoofing). Independently combinable
1006    /// with `send_x_real_ip`. Default: false.
1007    pub elide_x_real_ip: Option<bool>,
1008    /// When true, a proxy-generated `X-Real-IP` header carrying the
1009    /// connection peer IP (post-PROXY-v2 unwrap, i.e. the original client
1010    /// IP) is appended to every forwarded request. Independently combinable
1011    /// with `elide_x_real_ip`. Default: false.
1012    pub send_x_real_ip: Option<bool>,
1013    /// Which forwarding header family this listener adds to every forwarded
1014    /// request (see [`ForwardedHeadersMode`]). Independent of
1015    /// `elide_x_real_ip` and `send_x_real_ip`. Default: `both`.
1016    pub forwarded_headers: Option<ForwardedHeadersMode>,
1017    /// Per-status HTTP answer templates at listener scope — the **global
1018    /// default** that fires whenever no cluster-level override matches.
1019    /// Map key is the HTTP status code (e.g. `"503"`); map value is
1020    /// either a filesystem path or an `inline:<body>` literal, see
1021    /// [`resolve_answer_source`]. Loaded into
1022    /// [`HttpListenerConfig::answers`] / [`HttpsListenerConfig::answers`]
1023    /// at build time via [`load_answers`].
1024    ///
1025    /// Cluster-level [`FileClusterConfig::answers`] entries override the
1026    /// matching status here for requests routed to that cluster.
1027    ///
1028    /// The deprecated per-status `answer_NNN` fields are still honoured
1029    /// for backwards compatibility but are equivalent to a one-line entry
1030    /// in this map; new configs should prefer `[listeners.answers]`.
1031    pub answers: Option<BTreeMap<String, String>>,
1032    /// Listener-default HSTS (RFC 6797) policy. When set, every HTTPS
1033    /// frontend on this listener that does not declare its own `[hsts]`
1034    /// block inherits this value. Per RFC 6797 §7.2 HSTS is rejected on
1035    /// HTTP listeners at config-load time; this field is only meaningful
1036    /// for HTTPS listeners. Defaults to `None` (no HSTS).
1037    pub hsts: Option<FileHstsConfig>,
1038    /// UDP listener only: maximum received datagram size, in bytes. Capped
1039    /// at the effective `buffer_size` at config-load (clamp + warn when
1040    /// larger). Defaults to [`DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE`].
1041    pub max_rx_datagram_size: Option<u32>,
1042    /// UDP listener only: maximum number of concurrent flows. `0` (the
1043    /// default) selects the runtime auto policy (~70% soft RLIMIT_NOFILE);
1044    /// a warning is emitted at config-load when an explicit value exceeds
1045    /// that bound.
1046    pub max_flows: Option<u32>,
1047    /// TCP listener only: time allowed to receive enough bytes of the TLS
1048    /// ClientHello to read the SNI extension, in seconds. Only meaningful
1049    /// when at least one SNI-scoped frontend targets this listener.
1050    /// Defaults to [`DEFAULT_SNI_PREREAD_TIMEOUT`].
1051    pub sni_preread_timeout: Option<u32>,
1052    /// TCP listener only: maximum number of bytes buffered while prereading
1053    /// the TLS ClientHello looking for the SNI extension. Only meaningful
1054    /// when at least one SNI-scoped frontend targets this listener; must
1055    /// not exceed the global `buffer_size` (validated at config-load).
1056    /// Defaults to [`DEFAULT_SNI_PREREAD_MAX_BYTES`].
1057    pub sni_preread_max_bytes: Option<u32>,
1058    /// HTTPS listener only: mutual TLS (client certificate authentication)
1059    /// mode (`none` / `optional` / `required`). Absent and `none` both keep the
1060    /// historical no-client-auth behavior.
1061    pub client_auth: Option<ClientAuthConfig>,
1062    /// HTTPS listener only: filesystem paths to PEM-encoded trusted CA
1063    /// certificates a client certificate must chain to. Loaded at
1064    /// config-materialization time (like `certificate`/`key`) and inlined as
1065    /// PEM into the resulting [`HttpsListenerConfig`].
1066    pub client_ca_certificates: Option<Vec<String>>,
1067    /// HTTPS listener only: filesystem paths to PEM-encoded CRLs used to
1068    /// reject revoked client certificates. Loaded alongside
1069    /// `client_ca_certificates`.
1070    pub client_ca_crls: Option<Vec<String>>,
1071}
1072
1073pub fn default_sticky_name() -> String {
1074    DEFAULT_STICKY_NAME.to_string()
1075}
1076
1077impl ListenerBuilder {
1078    /// starts building an HTTP Listener with config values for timeouts,
1079    /// or defaults if no config is provided
1080    pub fn new_http(address: SocketAddress) -> ListenerBuilder {
1081        Self::new(address, ListenerProtocol::Http)
1082    }
1083
1084    /// starts building an HTTPS Listener with config values for timeouts,
1085    /// or defaults if no config is provided
1086    pub fn new_tcp(address: SocketAddress) -> ListenerBuilder {
1087        Self::new(address, ListenerProtocol::Tcp)
1088    }
1089
1090    /// starts building a TCP Listener with config values for timeouts,
1091    /// or defaults if no config is provided
1092    pub fn new_https(address: SocketAddress) -> ListenerBuilder {
1093        Self::new(address, ListenerProtocol::Https)
1094    }
1095
1096    /// starts building a UDP Listener with config values for timeouts,
1097    /// or defaults if no config is provided
1098    pub fn new_udp(address: SocketAddress) -> ListenerBuilder {
1099        Self::new(address, ListenerProtocol::Udp)
1100    }
1101
1102    /// starts building a Listener
1103    fn new(address: SocketAddress, protocol: ListenerProtocol) -> ListenerBuilder {
1104        ListenerBuilder {
1105            address: address.into(),
1106            interface: None,
1107            answer_301: None,
1108            answer_401: None,
1109            answer_400: None,
1110            answer_404: None,
1111            answer_408: None,
1112            answer_413: None,
1113            answer_421: None,
1114            answer_502: None,
1115            answer_503: None,
1116            answer_504: None,
1117            answer_507: None,
1118            answer_429: None,
1119            back_timeout: None,
1120            certificate_chain: None,
1121            certificate: None,
1122            cipher_list: None,
1123            cipher_suites: None,
1124            groups_list: None,
1125            config: None,
1126            connect_timeout: None,
1127            expect_proxy: None,
1128            front_timeout: None,
1129            key: None,
1130            protocol: Some(protocol),
1131            public_address: None,
1132            request_timeout: None,
1133            send_tls13_tickets: None,
1134            sticky_name: DEFAULT_STICKY_NAME.to_string(),
1135            tls_versions: None,
1136            alpn_protocols: None,
1137            h2_max_rst_stream_per_window: None,
1138            h2_max_ping_per_window: None,
1139            h2_max_settings_per_window: None,
1140            h2_max_empty_data_per_window: None,
1141            h2_max_window_update_stream0_per_window: None,
1142            sozu_id_header: None,
1143            h2_max_continuation_frames: None,
1144            h2_max_glitch_count: None,
1145            h2_initial_connection_window: None,
1146            h2_max_concurrent_streams: None,
1147            h2_stream_shrink_ratio: None,
1148            h2_max_rst_stream_lifetime: None,
1149            h2_max_rst_stream_abusive_lifetime: None,
1150            h2_max_rst_stream_emitted_lifetime: None,
1151            h2_max_header_list_size: None,
1152            h2_max_header_table_size: None,
1153            h2_max_header_fields: None,
1154            h2_stream_refusal_percent: None,
1155            h2_stream_idle_timeout_seconds: None,
1156            h2_graceful_shutdown_deadline_seconds: None,
1157            strict_sni_binding: None,
1158            disable_http11: None,
1159            elide_x_real_ip: None,
1160            send_x_real_ip: None,
1161            forwarded_headers: None,
1162            answers: None,
1163            hsts: None,
1164            max_rx_datagram_size: None,
1165            max_flows: None,
1166            sni_preread_timeout: None,
1167            sni_preread_max_bytes: None,
1168            client_auth: None,
1169            client_ca_certificates: None,
1170            client_ca_crls: None,
1171        }
1172    }
1173
1174    /// Set the network interface the listener binds to; `None` clears it.
1175    pub fn with_interface<S>(&mut self, interface: Option<S>) -> &mut Self
1176    where
1177        S: ToString,
1178    {
1179        self.interface = interface.map(|interface| interface.to_string());
1180        self
1181    }
1182
1183    /// The validated `interface`: a valid interface name, on a platform that
1184    /// can bind a socket to an interface.
1185    fn validated_interface(&self) -> Result<Option<String>, ConfigError> {
1186        if let Some(interface) = &self.interface {
1187            validate_listener_interface(interface).map_err(|error| {
1188                ConfigError::ListenerInterface {
1189                    address: self.address,
1190                    error,
1191                }
1192            })?;
1193        }
1194        Ok(self.interface.clone())
1195    }
1196
1197    pub fn with_public_address(&mut self, public_address: Option<SocketAddr>) -> &mut Self {
1198        if let Some(address) = public_address {
1199            self.public_address = Some(address);
1200        }
1201        self
1202    }
1203
1204    pub fn with_answer_404_path<S>(&mut self, answer_404_path: Option<S>) -> &mut Self
1205    where
1206        S: ToString,
1207    {
1208        if let Some(path) = answer_404_path {
1209            self.answer_404 = Some(path.to_string());
1210        }
1211        self
1212    }
1213
1214    pub fn with_answer_503_path<S>(&mut self, answer_503_path: Option<S>) -> &mut Self
1215    where
1216        S: ToString,
1217    {
1218        if let Some(path) = answer_503_path {
1219            self.answer_503 = Some(path.to_string());
1220        }
1221        self
1222    }
1223
1224    pub fn with_tls_versions(&mut self, tls_versions: Vec<TlsVersion>) -> &mut Self {
1225        self.tls_versions = Some(tls_versions);
1226        self
1227    }
1228
1229    pub fn with_cipher_list(&mut self, cipher_list: Option<Vec<String>>) -> &mut Self {
1230        self.cipher_list = cipher_list;
1231        self
1232    }
1233
1234    pub fn with_cipher_suites(&mut self, cipher_suites: Option<Vec<String>>) -> &mut Self {
1235        self.cipher_suites = cipher_suites;
1236        self
1237    }
1238
1239    pub fn with_alpn_protocols(&mut self, alpn_protocols: Option<Vec<String>>) -> &mut Self {
1240        self.alpn_protocols = alpn_protocols;
1241        self
1242    }
1243
1244    /// When true, strip any client-supplied `X-Real-IP` header from
1245    /// forwarded requests (anti-spoofing). Default: false.
1246    pub fn with_elide_x_real_ip(&mut self, elide_x_real_ip: bool) -> &mut Self {
1247        self.elide_x_real_ip = Some(elide_x_real_ip);
1248        self
1249    }
1250
1251    /// When true, append a proxy-generated `X-Real-IP` header carrying the
1252    /// connection peer IP (post-PROXY-v2 unwrap) to every forwarded request.
1253    /// Default: false.
1254    pub fn with_send_x_real_ip(&mut self, send_x_real_ip: bool) -> &mut Self {
1255        self.send_x_real_ip = Some(send_x_real_ip);
1256        self
1257    }
1258
1259    /// Which forwarding header family this listener adds to every forwarded
1260    /// request. Default: [`ForwardedHeadersMode::Both`].
1261    pub fn with_forwarded_headers(&mut self, forwarded_headers: ForwardedHeadersMode) -> &mut Self {
1262        self.forwarded_headers = Some(forwarded_headers);
1263        self
1264    }
1265
1266    pub fn with_expect_proxy(&mut self, expect_proxy: bool) -> &mut Self {
1267        self.expect_proxy = Some(expect_proxy);
1268        self
1269    }
1270
1271    pub fn with_sticky_name<S>(&mut self, sticky_name: Option<S>) -> &mut Self
1272    where
1273        S: ToString,
1274    {
1275        if let Some(name) = sticky_name {
1276            self.sticky_name = name.to_string();
1277        }
1278        self
1279    }
1280
1281    pub fn with_certificate<S>(&mut self, certificate: S) -> &mut Self
1282    where
1283        S: ToString,
1284    {
1285        self.certificate = Some(certificate.to_string());
1286        self
1287    }
1288
1289    pub fn with_certificate_chain(&mut self, certificate_chain: String) -> &mut Self {
1290        self.certificate = Some(certificate_chain);
1291        self
1292    }
1293
1294    pub fn with_key<S>(&mut self, key: String) -> &mut Self
1295    where
1296        S: ToString,
1297    {
1298        self.key = Some(key);
1299        self
1300    }
1301
1302    pub fn with_front_timeout(&mut self, front_timeout: Option<u32>) -> &mut Self {
1303        self.front_timeout = front_timeout;
1304        self
1305    }
1306
1307    pub fn with_back_timeout(&mut self, back_timeout: Option<u32>) -> &mut Self {
1308        self.back_timeout = back_timeout;
1309        self
1310    }
1311
1312    pub fn with_connect_timeout(&mut self, connect_timeout: Option<u32>) -> &mut Self {
1313        self.connect_timeout = connect_timeout;
1314        self
1315    }
1316
1317    pub fn with_request_timeout(&mut self, request_timeout: Option<u32>) -> &mut Self {
1318        self.request_timeout = request_timeout;
1319        self
1320    }
1321
1322    /// Register a single per-status answer template file path on this
1323    /// listener. The path is read off disk into the resulting listener's
1324    /// `answers` map at build time via [`load_answers`]. Repeated calls
1325    /// with the same status code overwrite the prior entry.
1326    pub fn with_answer<S, P>(&mut self, code: S, path: P) -> &mut Self
1327    where
1328        S: ToString,
1329        P: ToString,
1330    {
1331        self.answers
1332            .get_or_insert_with(BTreeMap::new)
1333            .insert(code.to_string(), path.to_string());
1334        self
1335    }
1336
1337    /// Replace the listener-scope answer-template path map. See
1338    /// [`Self::with_answer`].
1339    pub fn with_answers(&mut self, answers: BTreeMap<String, String>) -> &mut Self {
1340        self.answers = Some(answers);
1341        self
1342    }
1343
1344    /// Get the custom HTTP answers from the file system using the provided paths
1345    fn get_http_answers(&self) -> Result<Option<CustomHttpAnswers>, ConfigError> {
1346        let http_answers = CustomHttpAnswers {
1347            answer_301: read_http_answer_file(&self.answer_301)?,
1348            answer_400: read_http_answer_file(&self.answer_400)?,
1349            answer_401: read_http_answer_file(&self.answer_401)?,
1350            answer_404: read_http_answer_file(&self.answer_404)?,
1351            answer_408: read_http_answer_file(&self.answer_408)?,
1352            answer_413: read_http_answer_file(&self.answer_413)?,
1353            answer_421: read_http_answer_file(&self.answer_421)?,
1354            answer_502: read_http_answer_file(&self.answer_502)?,
1355            answer_503: read_http_answer_file(&self.answer_503)?,
1356            answer_504: read_http_answer_file(&self.answer_504)?,
1357            answer_507: read_http_answer_file(&self.answer_507)?,
1358            answer_429: read_http_answer_file(&self.answer_429)?,
1359        };
1360        Ok(Some(http_answers))
1361    }
1362
1363    /// Build the proto-side `answers` map for this listener.
1364    ///
1365    /// Merges, in order:
1366    /// 1. legacy per-status `answer_NNN` fields (if set), so legacy state
1367    ///    files round-trip into the new shape;
1368    /// 2. the explicit `[listeners.answers]` map (loaded via [`load_answers`]),
1369    ///    so new entries take precedence over legacy ones.
1370    fn get_listener_answers(&self) -> Result<BTreeMap<String, String>, ConfigError> {
1371        let mut out = BTreeMap::new();
1372
1373        // Pull bodies from the legacy per-status fields first so the new map
1374        // takes precedence on collision. Empty bodies are skipped to keep the
1375        // proto map minimal.
1376        macro_rules! merge_legacy {
1377            ($code:literal, $field:ident) => {
1378                if let Some(body) = read_http_answer_file(&self.$field)? {
1379                    out.insert($code.to_owned(), body);
1380                }
1381            };
1382        }
1383        merge_legacy!("301", answer_301);
1384        merge_legacy!("400", answer_400);
1385        merge_legacy!("401", answer_401);
1386        merge_legacy!("404", answer_404);
1387        merge_legacy!("408", answer_408);
1388        merge_legacy!("413", answer_413);
1389        merge_legacy!("421", answer_421);
1390        merge_legacy!("502", answer_502);
1391        merge_legacy!("503", answer_503);
1392        merge_legacy!("504", answer_504);
1393        merge_legacy!("507", answer_507);
1394        merge_legacy!("429", answer_429);
1395
1396        if let Some(map) = &self.answers {
1397            let loaded = load_answers(map)?;
1398            out.extend(loaded);
1399        }
1400        Ok(out)
1401    }
1402
1403    /// Assign the timeouts of the config to this listener, only if timeouts did not exist
1404    fn assign_config_timeouts(&mut self, config: &Config) {
1405        self.front_timeout = Some(self.front_timeout.unwrap_or(config.front_timeout));
1406        self.back_timeout = Some(self.back_timeout.unwrap_or(config.back_timeout));
1407        self.connect_timeout = Some(self.connect_timeout.unwrap_or(config.connect_timeout));
1408        self.request_timeout = Some(self.request_timeout.unwrap_or(config.request_timeout));
1409    }
1410
1411    /// Reject an H2 knob the runtime cannot honour, before it reaches a
1412    /// listener config (sozu-proxy/sozu#1418).
1413    ///
1414    /// `UpdateHttp(s)Listener` has refused these values since the knobs
1415    /// existed (`validate_h2_flood_knobs_http`/`_https`, `command/src/state.rs`)
1416    /// — the config file and `sozu ctl add listener` did not, so the same `0`
1417    /// an operator could not patch in, they could boot with. It reached
1418    /// `HttpListenerConfig` untouched, and `lib/src/http.rs::get_h2_flood_config`
1419    /// handed it to the flood detector, whose `check_flood` compares
1420    /// `count > threshold`: the first event the counter saw was already a
1421    /// violation. For `h2_max_header_list_size`/`h2_max_header_fields`, which
1422    /// double as the HPACK decode budget, that is every request on the
1423    /// listener; for the per-window and lifetime frame counters it is the
1424    /// first client that sends the frame they count. Rejecting here rather
1425    /// than clamping at use keeps the operator's mistake visible — the error
1426    /// names the key, the value and the minimum.
1427    ///
1428    /// The field set and the minimums are deliberately the same as
1429    /// `validate_h2_flood_knobs_http`'s: a value one door refuses must not get
1430    /// in through the other. `h2_graceful_shutdown_deadline_seconds = 0` stays
1431    /// allowed there and here — it means "wait forever".
1432    fn validate_h2_thresholds(&self) -> Result<(), ConfigError> {
1433        macro_rules! require_at_least {
1434            ($builder:ident, $field:ident, $minimum:literal) => {
1435                if let Some(value) = $builder.$field
1436                    && u64::from(value) < $minimum
1437                {
1438                    return Err(ConfigError::H2ThresholdBelowMinimum {
1439                        address: $builder.address.to_string(),
1440                        key: stringify!($field),
1441                        value: u64::from(value),
1442                        minimum: $minimum,
1443                    });
1444                }
1445            };
1446        }
1447        crate::for_each_h2_knob_floor!(require_at_least, self);
1448        Ok(())
1449    }
1450
1451    /// Reject mTLS fields on a non-HTTPS listener. Client auth is an
1452    /// HTTPS-termination control; the HTTP/TCP/UDP conversions have no field to
1453    /// carry it, so an operator who sets it there must get a typed error rather
1454    /// than a silently unauthenticated listener.
1455    fn reject_client_auth_fields(&self, listener_kind: &str) -> Result<(), ConfigError> {
1456        let has_mtls = self
1457            .client_auth
1458            .is_some_and(|m| m != ClientAuthConfig::None)
1459            || self
1460                .client_ca_certificates
1461                .as_ref()
1462                .is_some_and(|v| !v.is_empty())
1463            || self.client_ca_crls.as_ref().is_some_and(|v| !v.is_empty());
1464        if has_mtls {
1465            return Err(ConfigError::ClientAuthOnNonHttps(format!(
1466                "{} listener {}",
1467                listener_kind, self.address
1468            )));
1469        }
1470        Ok(())
1471    }
1472
1473    /// build an HTTP listener with config timeouts, using defaults if no config is provided
1474    pub fn to_http(&mut self, config: Option<&Config>) -> Result<HttpListenerConfig, ConfigError> {
1475        if self.protocol != Some(ListenerProtocol::Http) {
1476            return Err(ConfigError::WrongListenerProtocol {
1477                expected: ListenerProtocol::Http,
1478                found: self.protocol.to_owned(),
1479            });
1480        }
1481        self.reject_client_auth_fields("HTTP")?;
1482
1483        self.validate_h2_thresholds()?;
1484
1485        // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on
1486        // plaintext-HTTP responses. Reject an `[hsts]` block on an HTTP
1487        // listener at config-load — `HttpListenerConfig` has no `hsts`
1488        // field, so silently dropping the operator's intent would be a
1489        // worse failure mode than a typed error here.
1490        if self.hsts.is_some() {
1491            return Err(ConfigError::HstsOnPlainHttp(format!(
1492                "HTTP listener {}",
1493                self.address
1494            )));
1495        }
1496
1497        if let Some(config) = config {
1498            self.assign_config_timeouts(config);
1499        }
1500
1501        let http_answers = self.get_http_answers()?;
1502        let answers = self.get_listener_answers()?;
1503
1504        let interface = self.validated_interface()?;
1505        let configuration = HttpListenerConfig {
1506            address: self.address.into(),
1507            interface,
1508            public_address: self.public_address.map(|a| a.into()),
1509            expect_proxy: self.expect_proxy.unwrap_or(false),
1510            sticky_name: self.sticky_name.clone(),
1511            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1512            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1513            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1514            request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
1515            http_answers,
1516            answers,
1517            h2_max_rst_stream_per_window: self.h2_max_rst_stream_per_window,
1518            h2_max_ping_per_window: self.h2_max_ping_per_window,
1519            h2_max_settings_per_window: self.h2_max_settings_per_window,
1520            h2_max_empty_data_per_window: self.h2_max_empty_data_per_window,
1521            h2_max_window_update_stream0_per_window: self.h2_max_window_update_stream0_per_window,
1522            h2_max_continuation_frames: self.h2_max_continuation_frames,
1523            h2_max_glitch_count: self.h2_max_glitch_count,
1524            h2_initial_connection_window: self.h2_initial_connection_window,
1525            h2_max_concurrent_streams: self.h2_max_concurrent_streams,
1526            h2_stream_shrink_ratio: self.h2_stream_shrink_ratio,
1527            h2_max_rst_stream_lifetime: self.h2_max_rst_stream_lifetime,
1528            h2_max_rst_stream_abusive_lifetime: self.h2_max_rst_stream_abusive_lifetime,
1529            h2_max_rst_stream_emitted_lifetime: self.h2_max_rst_stream_emitted_lifetime,
1530            h2_max_header_list_size: self.h2_max_header_list_size,
1531            h2_max_header_table_size: self.h2_max_header_table_size,
1532            h2_max_header_fields: self.h2_max_header_fields,
1533            h2_stream_refusal_percent: self.h2_stream_refusal_percent,
1534            h2_stream_idle_timeout_seconds: self.h2_stream_idle_timeout_seconds,
1535            h2_graceful_shutdown_deadline_seconds: self.h2_graceful_shutdown_deadline_seconds,
1536            sozu_id_header: self.sozu_id_header.clone(),
1537            elide_x_real_ip: Some(self.elide_x_real_ip.unwrap_or(false)),
1538            send_x_real_ip: Some(self.send_x_real_ip.unwrap_or(false)),
1539            forwarded_headers: Some(ForwardedHeaders::from(
1540                self.forwarded_headers.unwrap_or_default(),
1541            ) as i32),
1542            ..Default::default()
1543        };
1544
1545        // POST: the built listener binds exactly the address that was
1546        // requested — a listener whose address drifted here would bind the
1547        // wrong socket. (We reached this point only because the protocol guard
1548        // at entry confirmed this is an HTTP listener.)
1549        debug_assert_eq!(
1550            configuration.address,
1551            self.address.into(),
1552            "HTTP listener must bind the requested address"
1553        );
1554        Ok(configuration)
1555    }
1556
1557    /// build an HTTPS listener using defaults if no config or values were provided upstream
1558    pub fn to_tls(&mut self, config: Option<&Config>) -> Result<HttpsListenerConfig, ConfigError> {
1559        if self.protocol != Some(ListenerProtocol::Https) {
1560            return Err(ConfigError::WrongListenerProtocol {
1561                expected: ListenerProtocol::Https,
1562                found: self.protocol.to_owned(),
1563            });
1564        }
1565
1566        self.validate_h2_thresholds()?;
1567
1568        let default_cipher_list = DEFAULT_CIPHER_LIST.into_iter().map(String::from).collect();
1569
1570        let cipher_list = self.cipher_list.clone().unwrap_or(default_cipher_list);
1571
1572        let cipher_suites = self
1573            .cipher_suites
1574            .clone()
1575            .unwrap_or_else(|| DEFAULT_CIPHER_LIST.into_iter().map(String::from).collect());
1576
1577        let signature_algorithms: Vec<String> = DEFAULT_SIGNATURE_ALGORITHMS
1578            .into_iter()
1579            .map(String::from)
1580            .collect();
1581
1582        let groups_list = self
1583            .groups_list
1584            .clone()
1585            .unwrap_or_else(|| DEFAULT_GROUPS_LIST.into_iter().map(String::from).collect());
1586
1587        let alpn_protocols: Vec<String> = match &self.alpn_protocols {
1588            Some(protos) if !protos.is_empty() => {
1589                for proto in protos {
1590                    match proto.as_str() {
1591                        "h2" | "http/1.1" => {}
1592                        other => return Err(ConfigError::InvalidAlpnProtocol(other.to_owned())),
1593                    }
1594                }
1595                // disable_http11 + http/1.1 ALPN is a self-DoS — every
1596                // connection negotiates http/1.1 then is
1597                // immediately refused at `https.rs::upgrade_handshake`.
1598                // Reject the combination at config load.
1599                if self.disable_http11.unwrap_or(false) && protos.iter().any(|p| p == "http/1.1") {
1600                    return Err(ConfigError::DisableHttp11WithHttp11Alpn {
1601                        address: self.address.to_string(),
1602                    });
1603                }
1604                if !protos.iter().any(|p| p == "http/1.1") {
1605                    warn!(
1606                        "ALPN protocols do not include 'http/1.1'. Clients without H2 support will fail TLS negotiation."
1607                    );
1608                }
1609                // Deduplicate while preserving order
1610                let mut seen = std::collections::HashSet::new();
1611                protos
1612                    .iter()
1613                    .filter(|p| seen.insert(p.as_str()))
1614                    .cloned()
1615                    .collect()
1616            }
1617            _ => {
1618                // Same self-DoS check on the default ALPN list (which
1619                // contains "http/1.1") — `disable_http11 = true` with the
1620                // implicit default ALPN must also be rejected.
1621                if self.disable_http11.unwrap_or(false)
1622                    && DEFAULT_ALPN_PROTOCOLS.contains(&"http/1.1")
1623                {
1624                    return Err(ConfigError::DisableHttp11WithHttp11Alpn {
1625                        address: self.address.to_string(),
1626                    });
1627                }
1628                DEFAULT_ALPN_PROTOCOLS
1629                    .iter()
1630                    .map(|s| s.to_string())
1631                    .collect()
1632            }
1633        };
1634
1635        let versions = match self.tls_versions {
1636            None => vec![TlsVersion::TlsV12 as i32, TlsVersion::TlsV13 as i32],
1637            Some(ref v) => v.iter().map(|v| *v as i32).collect(),
1638        };
1639
1640        let key = self.key.as_ref().and_then(|path| {
1641            Config::load_file(path)
1642                .map_err(|e| {
1643                    error!("cannot load key at path '{}': {:?}", path, e);
1644                    e
1645                })
1646                .ok()
1647        });
1648        let certificate = self.certificate.as_ref().and_then(|path| {
1649            Config::load_file(path)
1650                .map_err(|e| {
1651                    error!("cannot load certificate at path '{}': {:?}", path, e);
1652                    e
1653                })
1654                .ok()
1655        });
1656        let certificate_chain = self
1657            .certificate_chain
1658            .as_ref()
1659            .and_then(|path| {
1660                Config::load_file(path)
1661                    .map_err(|e| {
1662                        error!("cannot load certificate chain at path '{}': {:?}", path, e);
1663                        e
1664                    })
1665                    .ok()
1666            })
1667            .map(split_certificate_chain)
1668            .unwrap_or_default();
1669
1670        // mTLS: load the trusted-CA and CRL PEM bundles from disk, inlining
1671        // their contents (like `certificate`/`key` above). Any unreadable path
1672        // aborts materialization: a dropped CA would weaken trust, and a
1673        // dropped CRL would silently disable revocation for certificates the
1674        // operator meant to reject. Failing closed keeps client auth honest.
1675        //
1676        // Files are read only for OPTIONAL/REQUIRED. In NONE the runtime
1677        // ignores CA/CRL data, so a stale path left in the config must not
1678        // block the whole configuration from loading. That tolerance needs an
1679        // explicit `client_auth = "none"`: CA or CRL files with no mode at all
1680        // most likely mean the operator expected client authentication, and
1681        // the default would start the listener without it.
1682        let has_client_auth_files = self
1683            .client_ca_certificates
1684            .as_ref()
1685            .is_some_and(|paths| !paths.is_empty())
1686            || self
1687                .client_ca_crls
1688                .as_ref()
1689                .is_some_and(|paths| !paths.is_empty());
1690        match self.client_auth {
1691            None if has_client_auth_files => {
1692                return Err(ConfigError::ClientAuthModeMissing(format!(
1693                    "HTTPS listener {}",
1694                    self.address
1695                )));
1696            }
1697            Some(ClientAuthConfig::None) if has_client_auth_files => {
1698                warn!(
1699                    "HTTPS listener {}: client_auth is \"none\", so its client_ca_certificates \
1700                     and client_ca_crls are ignored and clients are not authenticated",
1701                    self.address
1702                );
1703            }
1704            _ => {}
1705        }
1706        let mode = self.client_auth.unwrap_or_default();
1707        let client_auth = self
1708            .client_auth
1709            .map(|mode| ClientAuthMode::from(mode) as i32);
1710        let load_pem_paths = |paths: &Option<Vec<String>>| -> Result<Vec<String>, ConfigError> {
1711            if mode == ClientAuthConfig::None {
1712                return Ok(Vec::new());
1713            }
1714            paths
1715                .as_ref()
1716                .map(|list| list.iter().map(|path| Config::load_file(path)).collect())
1717                .unwrap_or_else(|| Ok(Vec::new()))
1718        };
1719        let client_ca_certificates = load_pem_paths(&self.client_ca_certificates)?;
1720        let client_ca_crls = load_pem_paths(&self.client_ca_crls)?;
1721
1722        let http_answers = self.get_http_answers()?;
1723        let answers = self.get_listener_answers()?;
1724
1725        if let Some(config) = config {
1726            self.assign_config_timeouts(config);
1727        }
1728
1729        let https_listener_config = HttpsListenerConfig {
1730            interface: self.validated_interface()?,
1731            address: self.address.into(),
1732            sticky_name: self.sticky_name.clone(),
1733            public_address: self.public_address.map(|a| a.into()),
1734            cipher_list,
1735            versions,
1736            expect_proxy: self.expect_proxy.unwrap_or(false),
1737            key,
1738            certificate,
1739            certificate_chain,
1740            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1741            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1742            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1743            request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
1744            cipher_suites,
1745            signature_algorithms,
1746            groups_list,
1747            active: false,
1748            send_tls13_tickets: self
1749                .send_tls13_tickets
1750                .unwrap_or(DEFAULT_SEND_TLS_13_TICKETS),
1751            http_answers,
1752            answers,
1753            alpn_protocols,
1754            h2_max_rst_stream_per_window: self.h2_max_rst_stream_per_window,
1755            h2_max_ping_per_window: self.h2_max_ping_per_window,
1756            h2_max_settings_per_window: self.h2_max_settings_per_window,
1757            h2_max_empty_data_per_window: self.h2_max_empty_data_per_window,
1758            h2_max_window_update_stream0_per_window: self.h2_max_window_update_stream0_per_window,
1759            h2_max_continuation_frames: self.h2_max_continuation_frames,
1760            h2_max_glitch_count: self.h2_max_glitch_count,
1761            h2_initial_connection_window: self.h2_initial_connection_window,
1762            h2_max_concurrent_streams: self.h2_max_concurrent_streams,
1763            h2_stream_shrink_ratio: self.h2_stream_shrink_ratio,
1764            h2_max_rst_stream_lifetime: self.h2_max_rst_stream_lifetime,
1765            h2_max_rst_stream_abusive_lifetime: self.h2_max_rst_stream_abusive_lifetime,
1766            h2_max_rst_stream_emitted_lifetime: self.h2_max_rst_stream_emitted_lifetime,
1767            h2_max_header_list_size: self.h2_max_header_list_size,
1768            h2_max_header_table_size: self.h2_max_header_table_size,
1769            h2_max_header_fields: self.h2_max_header_fields,
1770            h2_stream_refusal_percent: self.h2_stream_refusal_percent,
1771            strict_sni_binding: self.strict_sni_binding,
1772            disable_http11: self.disable_http11,
1773            h2_stream_idle_timeout_seconds: self.h2_stream_idle_timeout_seconds,
1774            h2_graceful_shutdown_deadline_seconds: self.h2_graceful_shutdown_deadline_seconds,
1775            sozu_id_header: self.sozu_id_header.clone(),
1776            elide_x_real_ip: Some(self.elide_x_real_ip.unwrap_or(false)),
1777            send_x_real_ip: Some(self.send_x_real_ip.unwrap_or(false)),
1778            forwarded_headers: Some(ForwardedHeaders::from(
1779                self.forwarded_headers.unwrap_or_default(),
1780            ) as i32),
1781            hsts: match self.hsts.as_ref() {
1782                Some(h) => Some(h.to_proto("listener")?),
1783                None => None,
1784            },
1785            client_auth,
1786            client_ca_certificates,
1787            client_ca_crls,
1788        };
1789
1790        // POST: the built listener binds the requested address and starts
1791        // inactive (the protocol guard at entry confirmed this is an HTTPS
1792        // listener).
1793        debug_assert_eq!(
1794            https_listener_config.address,
1795            self.address.into(),
1796            "HTTPS listener must bind the requested address"
1797        );
1798        debug_assert!(
1799            !https_listener_config.active,
1800            "a freshly built HTTPS listener must start inactive"
1801        );
1802        // POST: the resolved ALPN list is non-empty, contains only the two
1803        // protocols Sōzu speaks, and is duplicate-free — the validation/dedup
1804        // branches above are the sole producers, so a malformed list here would
1805        // mean an unvalidated path slipped through.
1806        debug_assert!(
1807            !https_listener_config.alpn_protocols.is_empty(),
1808            "resolved ALPN list must not be empty"
1809        );
1810        debug_assert!(
1811            https_listener_config
1812                .alpn_protocols
1813                .iter()
1814                .all(|p| p == "h2" || p == "http/1.1"),
1815            "resolved ALPN list must contain only h2 and http/1.1"
1816        );
1817        debug_assert!(
1818            {
1819                let mut seen = std::collections::HashSet::new();
1820                https_listener_config
1821                    .alpn_protocols
1822                    .iter()
1823                    .all(|p| seen.insert(p))
1824            },
1825            "resolved ALPN list must be duplicate-free"
1826        );
1827        // POST: disable_http11 + http/1.1 in ALPN is a self-DoS that the
1828        // validation above rejects — an Ok return must never carry that combo.
1829        debug_assert!(
1830            !(self.disable_http11.unwrap_or(false)
1831                && https_listener_config
1832                    .alpn_protocols
1833                    .iter()
1834                    .any(|p| p == "http/1.1")),
1835            "disable_http11 with http/1.1 in ALPN must have been rejected"
1836        );
1837        Ok(https_listener_config)
1838    }
1839
1840    /// build an HTTPS listener using defaults if no config or values were provided upstream
1841    pub fn to_tcp(&mut self, config: Option<&Config>) -> Result<TcpListenerConfig, ConfigError> {
1842        if self.protocol != Some(ListenerProtocol::Tcp) {
1843            return Err(ConfigError::WrongListenerProtocol {
1844                expected: ListenerProtocol::Tcp,
1845                found: self.protocol.to_owned(),
1846            });
1847        }
1848        self.reject_client_auth_fields("TCP")?;
1849
1850        if let Some(config) = config {
1851            self.assign_config_timeouts(config);
1852        }
1853
1854        let tcp_listener_config = TcpListenerConfig {
1855            interface: self.validated_interface()?,
1856            address: self.address.into(),
1857            public_address: self.public_address.map(|a| a.into()),
1858            expect_proxy: self.expect_proxy.unwrap_or(false),
1859            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1860            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1861            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1862            active: false,
1863            sni_preread_timeout: Some(
1864                self.sni_preread_timeout
1865                    .unwrap_or(DEFAULT_SNI_PREREAD_TIMEOUT),
1866            ),
1867            sni_preread_max_bytes: Some(
1868                self.sni_preread_max_bytes
1869                    .unwrap_or(DEFAULT_SNI_PREREAD_MAX_BYTES),
1870            ),
1871        };
1872
1873        // POST: the built listener binds exactly the requested address and is
1874        // created inactive (activation is a later, explicit step).
1875        debug_assert_eq!(
1876            tcp_listener_config.address,
1877            self.address.into(),
1878            "TCP listener must bind the requested address"
1879        );
1880        debug_assert!(
1881            !tcp_listener_config.active,
1882            "a freshly built TCP listener must start inactive"
1883        );
1884        Ok(tcp_listener_config)
1885    }
1886
1887    /// build a UDP listener. UDP has no `expect_proxy` / `connect_timeout`;
1888    /// flows are keyed by 4-tuple and torn down on idle.
1889    ///
1890    /// Timeouts: an unset `front_timeout` / `back_timeout` falls back to the
1891    /// UDP-specific defaults ([`DEFAULT_UDP_FRONT_TIMEOUT`] /
1892    /// [`DEFAULT_UDP_BACK_TIMEOUT`], both 30 s) — *not* the global HTTP/TCP
1893    /// `front_timeout` (60 s) / `back_timeout`. This keeps the effective
1894    /// default in lock-step with the CLI help and the proto
1895    /// `UdpListenerConfig` defaults (both 30 s). The global config is consulted
1896    /// only for `buffer_size` (the `max_rx_datagram_size` cap).
1897    ///
1898    /// Validation:
1899    /// * `max_rx_datagram_size` is clamped to the effective `buffer_size`
1900    ///   (with a warning) so a datagram can never exceed the pool buffer.
1901    /// * an explicit non-zero `max_flows` that exceeds ~70% of the soft
1902    ///   `RLIMIT_NOFILE` emits a warning (the per-flow connected sockets
1903    ///   would otherwise risk EMFILE).
1904    pub fn to_udp(&mut self, config: Option<&Config>) -> Result<UdpListenerConfig, ConfigError> {
1905        if self.protocol != Some(ListenerProtocol::Udp) {
1906            return Err(ConfigError::WrongListenerProtocol {
1907                expected: ListenerProtocol::Udp,
1908                found: self.protocol.to_owned(),
1909            });
1910        }
1911        self.reject_client_auth_fields("UDP")?;
1912
1913        let mut max_rx_datagram_size = self
1914            .max_rx_datagram_size
1915            .unwrap_or(DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE);
1916        let buffer_size = config.map(|c| c.buffer_size).unwrap_or(DEFAULT_BUFFER_SIZE);
1917        if u64::from(max_rx_datagram_size) > buffer_size {
1918            warn!(
1919                "UDP listener {}: max_rx_datagram_size = {} exceeds buffer_size = {}, clamping to buffer_size",
1920                self.address, max_rx_datagram_size, buffer_size
1921            );
1922            max_rx_datagram_size = buffer_size as u32;
1923        }
1924
1925        let max_flows = self.max_flows.unwrap_or(DEFAULT_UDP_MAX_FLOWS);
1926        if max_flows > 0
1927            && let Some(soft_limit) = soft_rlimit_nofile()
1928        {
1929            let advisory = soft_limit.saturating_mul(7) / 10;
1930            if u64::from(max_flows) > advisory {
1931                warn!(
1932                    "UDP listener {}: max_flows = {} exceeds ~70% of the soft RLIMIT_NOFILE ({}); \
1933                         per-flow connected sockets may hit EMFILE",
1934                    self.address, max_flows, advisory
1935                );
1936            }
1937        }
1938
1939        Ok(UdpListenerConfig {
1940            interface: self.validated_interface()?,
1941            address: self.address.into(),
1942            public_address: self.public_address.map(|a| a.into()),
1943            front_timeout: self.front_timeout.unwrap_or(DEFAULT_UDP_FRONT_TIMEOUT),
1944            back_timeout: self.back_timeout.unwrap_or(DEFAULT_UDP_BACK_TIMEOUT),
1945            max_rx_datagram_size,
1946            max_flows,
1947            active: false,
1948        })
1949    }
1950}
1951
1952/// Read the soft `RLIMIT_NOFILE` (max open file descriptors). Used as an
1953/// advisory ceiling for `max_flows` on UDP listeners. Returns `None` when
1954/// the limit cannot be read so callers skip the advisory check rather than
1955/// failing config-load.
1956fn soft_rlimit_nofile() -> Option<u64> {
1957    let mut limit = libc::rlimit {
1958        rlim_cur: 0,
1959        rlim_max: 0,
1960    };
1961    // SAFETY: `getrlimit` writes into the provided `rlimit` out-parameter and
1962    // does not retain the pointer. A non-zero return means failure, in which
1963    // case we ignore the (uninitialised-by-contract) value and return None.
1964    let rc = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) };
1965    if rc == 0 {
1966        // `rlim_cur` is `rlim_t`, which is `u64` on the Tier-1 targets sozu
1967        // builds for, so it already matches the `Option<u64>` return type.
1968        Some(limit.rlim_cur)
1969    } else {
1970        None
1971    }
1972}
1973
1974/// read a custom HTTP answer from a file
1975fn read_http_answer_file(path: &Option<String>) -> Result<Option<String>, ConfigError> {
1976    match path {
1977        Some(path) => {
1978            let mut content = String::new();
1979            let mut file = File::open(path).map_err(|io_error| ConfigError::FileOpen {
1980                path_to_open: path.to_owned(),
1981                io_error,
1982            })?;
1983
1984            file.read_to_string(&mut content)
1985                .map_err(|io_error| ConfigError::FileRead {
1986                    path_to_read: path.to_owned(),
1987                    io_error,
1988                })?;
1989
1990            Ok(Some(content))
1991        }
1992        None => Ok(None),
1993    }
1994}
1995
1996/// Resolve a single `answers` map entry into the literal template body
1997/// the proto layer expects.
1998///
1999/// The same resolution rule applies to entries at every layer:
2000/// * **Listener-level** `[listeners.<id>.answers]` — the global default
2001///   that fires whenever no more specific override matches.
2002/// * **Cluster-level** `[clusters.<id>.answers]` — overrides the
2003///   listener-level default for the matching status code on requests
2004///   routed to that cluster.
2005///
2006/// Two source forms are accepted:
2007/// * **Filesystem path** — the value starts with the `file://` URI
2008///   scheme. Everything after the prefix is treated as a path; the
2009///   path is opened and read into a string. Mirrors the on-disk
2010///   loading the per-status `read_http_answer_file` helper performs
2011///   for the deprecated `answer_301`..`answer_507` fields.
2012/// * **Inline literal** (default) — anything else. The value is taken
2013///   verbatim as the template body, including an empty string (a
2014///   0-byte response payload, typical with `Connection: close` and no
2015///   headers). The bare-string default keeps the common case — a
2016///   short canned response — typing-light; operators who need a file
2017///   say so explicitly with `file://`.
2018pub fn resolve_answer_source(value: &str) -> Result<String, ConfigError> {
2019    if let Some(path) = value.strip_prefix("file://") {
2020        let mut content = String::new();
2021        let mut file = File::open(path).map_err(|io_error| ConfigError::FileOpen {
2022            path_to_open: path.to_owned(),
2023            io_error,
2024        })?;
2025        file.read_to_string(&mut content)
2026            .map_err(|io_error| ConfigError::FileRead {
2027                path_to_read: path.to_owned(),
2028                io_error,
2029            })?;
2030        return Ok(content);
2031    }
2032    Ok(value.to_owned())
2033}
2034
2035/// Load every per-status template referenced by `answers`.
2036///
2037/// `answers` maps an HTTP status code (e.g. `"503"`) to either a
2038/// filesystem path or an `inline:<body>` literal — see
2039/// [`resolve_answer_source`] for the resolution rules. Each entry is
2040/// resolved into a body string and inserted into the returned map
2041/// under the same key, ready to be assigned to the proto-level
2042/// `answers` field on a [`HttpListenerConfig`] / [`HttpsListenerConfig`]
2043/// / [`Cluster`]. Empty values are skipped (treated as "preserve
2044/// current") so the caller can use them as a no-op stub in example
2045/// configs.
2046///
2047/// Errors map to the existing `ConfigError::FileOpen` /
2048/// `ConfigError::FileRead` variants so the operator gets the same
2049/// diagnostics whether the path comes from this map or from the
2050/// deprecated per-status `answer_301`..`answer_507` fields.
2051pub fn load_answers(
2052    answers: &BTreeMap<String, String>,
2053) -> Result<BTreeMap<String, String>, ConfigError> {
2054    let mut out = BTreeMap::new();
2055    for (code, value) in answers {
2056        if value.is_empty() {
2057            continue;
2058        }
2059        out.insert(code.to_owned(), resolve_answer_source(value)?);
2060    }
2061    // POST: the loaded map never invents a status code (every output key is an
2062    // input key) and never grows past the input — empty-valued entries are
2063    // skipped, so |out| <= |answers|.
2064    debug_assert!(
2065        out.len() <= answers.len(),
2066        "load_answers must not synthesize entries"
2067    );
2068    debug_assert!(
2069        out.keys().all(|k| answers.contains_key(k)),
2070        "every loaded status code must come from the input map"
2071    );
2072    Ok(out)
2073}
2074
2075/// Cardinality knob for metrics labels in the StatsD network drain.
2076///
2077/// Mirrors HAProxy's `process|frontend|backend|server` extra-counters opt-in.
2078/// Operators choose the lowest level that satisfies their dashboards so that
2079/// the keyspace stays bounded. Each level is a SUPERSET of the previous one:
2080///
2081/// - `process` — proxy-only counters (no listener, cluster, or backend label).
2082/// - `frontend` — adds per-listener (frontend) breakdown.
2083/// - `cluster` — adds per-cluster aggregation. **Default** (preserves the
2084///   pre-knob behaviour).
2085/// - `backend` — adds per-backend aggregation (cluster + backend, highest
2086///   cardinality).
2087#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
2088#[serde(rename_all = "lowercase")]
2089#[derive(Default)]
2090pub enum MetricDetailLevel {
2091    Process,
2092    Frontend,
2093    #[default]
2094    Cluster,
2095    Backend,
2096}
2097
2098impl From<MetricDetailLevel> for MetricDetail {
2099    fn from(level: MetricDetailLevel) -> Self {
2100        match level {
2101            MetricDetailLevel::Process => MetricDetail::DetailProcess,
2102            MetricDetailLevel::Frontend => MetricDetail::DetailFrontend,
2103            MetricDetailLevel::Cluster => MetricDetail::DetailCluster,
2104            MetricDetailLevel::Backend => MetricDetail::DetailBackend,
2105        }
2106    }
2107}
2108
2109impl From<MetricDetail> for MetricDetailLevel {
2110    /// Reverse of [`From<MetricDetailLevel> for MetricDetail`] — used by the
2111    /// worker side to convert the protobuf wire enum back into the
2112    /// configuration enum before passing it to `sozu_lib::metrics::setup`.
2113    fn from(detail: MetricDetail) -> Self {
2114        match detail {
2115            MetricDetail::DetailProcess => MetricDetailLevel::Process,
2116            MetricDetail::DetailFrontend => MetricDetailLevel::Frontend,
2117            MetricDetail::DetailCluster => MetricDetailLevel::Cluster,
2118            MetricDetail::DetailBackend => MetricDetailLevel::Backend,
2119        }
2120    }
2121}
2122
2123/// The forwarding header family an HTTP or HTTPS listener adds to every
2124/// forwarded request, as spelled in the TOML configuration
2125/// (`forwarded_headers = "both"`). The wire form is [`ForwardedHeaders`].
2126///
2127/// - `both` — **default**, the historical behaviour: `X-Forwarded-For`,
2128///   `X-Forwarded-Proto`, `X-Forwarded-Port` and RFC 7239 `Forwarded`, a
2129///   client-supplied chain extended with this hop.
2130/// - `x_forwarded` — only the `X-Forwarded-*` family, handled as in `both`;
2131///   a client-supplied `Forwarded` passes through untouched.
2132/// - `rfc7239` — only `Forwarded` (RFC 7239 §4), a client-supplied chain
2133///   extended with this hop; a client-supplied `X-Forwarded-For`,
2134///   `X-Forwarded-Proto`, `X-Forwarded-Port` or `X-Forwarded-Host` is removed.
2135/// - `none` — no forwarding header is added and client-supplied ones pass
2136///   through untouched.
2137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
2138#[serde(rename_all = "snake_case")]
2139pub enum ForwardedHeadersMode {
2140    #[default]
2141    Both,
2142    XForwarded,
2143    Rfc7239,
2144    None,
2145}
2146
2147impl From<ForwardedHeadersMode> for ForwardedHeaders {
2148    fn from(mode: ForwardedHeadersMode) -> Self {
2149        match mode {
2150            ForwardedHeadersMode::Both => ForwardedHeaders::Both,
2151            ForwardedHeadersMode::XForwarded => ForwardedHeaders::XForwarded,
2152            ForwardedHeadersMode::Rfc7239 => ForwardedHeaders::Rfc7239,
2153            ForwardedHeadersMode::None => ForwardedHeaders::None,
2154        }
2155    }
2156}
2157
2158impl From<ForwardedHeaders> for ForwardedHeadersMode {
2159    fn from(headers: ForwardedHeaders) -> Self {
2160        match headers {
2161            ForwardedHeaders::Both => ForwardedHeadersMode::Both,
2162            ForwardedHeaders::XForwarded => ForwardedHeadersMode::XForwarded,
2163            ForwardedHeaders::Rfc7239 => ForwardedHeadersMode::Rfc7239,
2164            ForwardedHeaders::None => ForwardedHeadersMode::None,
2165        }
2166    }
2167}
2168
2169#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2170#[serde(deny_unknown_fields)]
2171pub struct MetricsConfig {
2172    pub address: SocketAddr,
2173    #[serde(default)]
2174    pub tagged_metrics: bool,
2175    #[serde(default)]
2176    pub prefix: Option<String>,
2177    /// Cardinality knob for label-aware metrics. Defaults to `cluster` to
2178    /// preserve historical behaviour. See [`MetricDetailLevel`].
2179    #[serde(default)]
2180    pub detail: MetricDetailLevel,
2181}
2182
2183#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2184#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
2185#[serde(deny_unknown_fields)]
2186pub enum PathRuleType {
2187    Prefix,
2188    Regex,
2189    Equals,
2190}
2191
2192#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2193#[serde(deny_unknown_fields)]
2194pub struct FileClusterFrontendConfig {
2195    pub address: SocketAddr,
2196    pub hostname: Option<String>,
2197    /// TCP frontend only (sozu-proxy/sozu#1279): ALPN protocol names to
2198    /// match, read from the TLS ClientHello during the same preread that
2199    /// resolves `hostname` (mapped to the wire `sni` field for TCP
2200    /// frontends). Empty (the default) is the catch-all for this
2201    /// frontend's `hostname`/SNI on its listener. Rejected on HTTP/HTTPS
2202    /// frontends — ALPN there is negotiated by the listener's
2203    /// `alpn_protocols`, not per-frontend.
2204    #[serde(default)]
2205    pub alpn: Vec<String>,
2206    /// creates a path routing rule where the request URL path has to match this
2207    pub path: Option<String>,
2208    /// declares whether the path rule is Prefix (default), Regex, or Equals
2209    pub path_type: Option<PathRuleType>,
2210    pub method: Option<String>,
2211    pub certificate: Option<String>,
2212    pub key: Option<String>,
2213    pub certificate_chain: Option<String>,
2214    #[serde(default)]
2215    pub tls_versions: Vec<TlsVersion>,
2216    #[serde(default)]
2217    pub position: RulePosition,
2218    pub tags: Option<BTreeMap<String, String>>,
2219    /// Frontend-level redirect policy. Accepted values are `forward`
2220    /// (default — route to the backend), `permanent` (return 301 with the
2221    /// computed `Location`), or `unauthorized` (return 401 with
2222    /// `WWW-Authenticate: Basic realm=…`). Case-insensitive.
2223    pub redirect: Option<String>,
2224    /// Scheme used when emitting a permanent redirect's `Location`. Accepted
2225    /// values are `use-same` (default — preserve request scheme), `use-http`,
2226    /// `use-https`. Case-insensitive.
2227    pub redirect_scheme: Option<String>,
2228    /// Optional template applied to the emitted permanent-redirect response
2229    /// body. Supports the `%REDIRECT_LOCATION` and other variables
2230    /// documented in `doc/configure.md`.
2231    pub redirect_template: Option<String>,
2232    /// Rewrite host template. Supports `$HOST[n]` / `$PATH[n]` placeholders
2233    /// populated from regex captures collected during routing.
2234    pub rewrite_host: Option<String>,
2235    /// Rewrite path template. Same grammar as `rewrite_host`.
2236    pub rewrite_path: Option<String>,
2237    /// Optional literal port override on the rewritten URL.
2238    pub rewrite_port: Option<u32>,
2239    /// When true, requests routed through this frontend must carry a valid
2240    /// `Authorization: Basic <user:pass>` header whose hash matches one of
2241    /// the cluster's `authorized_hashes`. Default: false.
2242    pub required_auth: Option<bool>,
2243    /// Header mutations applied to requests and/or responses passing through
2244    /// this frontend. See [`HeaderEditConfig`] for the empty-value-deletes
2245    /// semantics (HAProxy `del-header` parity).
2246    pub headers: Option<Vec<HeaderEditConfig>>,
2247    /// Per-frontend HSTS (RFC 6797) policy. When set, overrides any
2248    /// listener-default HSTS for this frontend. Set `enabled = false`
2249    /// to suppress an inherited listener default. Per RFC 6797 §7.2,
2250    /// HSTS is rejected on plain-HTTP frontends at config-load time.
2251    pub hsts: Option<FileHstsConfig>,
2252    /// Header predicates the request must all satisfy for this frontend to
2253    /// match (sozu-proxy/sozu#1253). See [`HeaderPredicateConfig`].
2254    pub header_predicates: Option<Vec<HeaderPredicateConfig>>,
2255}
2256
2257/// A routing condition on one request header, as serialised under
2258/// `clusters.<id>.frontends.header_predicates` (sozu-proxy/sozu#1253).
2259/// Maps to the proto [`HeaderPredicate`] at request-build time.
2260///
2261/// `match` accepts `exact`, `regex`, or `present` (case-insensitive).
2262/// `value` is required for `exact` and `regex` and forbidden for
2263/// `present`.
2264#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2265#[serde(deny_unknown_fields)]
2266pub struct HeaderPredicateConfig {
2267    pub name: String,
2268    #[serde(rename = "match")]
2269    pub kind: String,
2270    pub value: Option<String>,
2271}
2272
2273/// A single header mutation as serialised under
2274/// `[[clusters.<id>.frontends.headers]]`. Maps to the proto [`Header`]
2275/// message at request-build time.
2276///
2277/// `position` accepts `request`, `response`, or `both` (case-insensitive).
2278/// An empty `value` deletes the header by name (HAProxy `del-header` parity).
2279#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2280#[serde(deny_unknown_fields)]
2281pub struct HeaderEditConfig {
2282    pub position: String,
2283    pub key: String,
2284    pub value: String,
2285}
2286
2287/// HSTS (HTTP Strict Transport Security, RFC 6797) policy as serialised
2288/// under `[https.listeners.default.hsts]` (listener default) or
2289/// `[clusters.<id>.frontends.hsts]` (per-frontend override).
2290///
2291/// `enabled` is REQUIRED whenever the block is present — its presence vs
2292/// absence disambiguates "preserve current" / "explicit disable" / "enable"
2293/// on hot-reconfig partial updates.
2294///
2295/// When `enabled = true` and `max_age` is omitted, sozu substitutes
2296/// [`DEFAULT_HSTS_MAX_AGE`] (1 year) at config-load time.
2297#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2298#[serde(deny_unknown_fields)]
2299pub struct FileHstsConfig {
2300    /// REQUIRED. `true` enables HSTS for this scope; `false` suppresses
2301    /// any inherited listener default (explicit-disable signal).
2302    pub enabled: Option<bool>,
2303    /// `Strict-Transport-Security: max-age=<seconds>`. Optional —
2304    /// defaults to [`DEFAULT_HSTS_MAX_AGE`] when `enabled = true`.
2305    /// `max_age = 0` is the RFC 6797 §11.4 kill switch and is allowed
2306    /// silently; `0 < max_age < 86400` warns at config-load.
2307    pub max_age: Option<u32>,
2308    /// Append `; includeSubDomains` to the rendered header.
2309    pub include_subdomains: Option<bool>,
2310    /// Append `; preload` to the rendered header. Opt-in only — see RFC
2311    /// 6797 §14.2 and <https://hstspreload.org/>.
2312    pub preload: Option<bool>,
2313    /// Operator opt-in to override any backend-supplied
2314    /// `Strict-Transport-Security` header. RFC 6797 §6.1 default
2315    /// behaviour is to PRESERVE the backend's value (sozu's edit uses
2316    /// `HeaderEditMode::SetIfAbsent`). Set this to `true` to harden a
2317    /// stale or weak upstream HSTS policy centrally — the materialiser
2318    /// then uses `HeaderEditMode::Set`, replacing any backend STS with
2319    /// sozu's rendered value.
2320    pub force_replace_backend: Option<bool>,
2321}
2322
2323impl FileHstsConfig {
2324    /// Validate and convert the file-level [`FileHstsConfig`] into the
2325    /// proto [`HstsConfig`]. `scope` is a human-readable string (e.g.
2326    /// "listener" or "frontend api/example.com") surfaced into errors
2327    /// and warnings so the operator can pinpoint the offending block.
2328    ///
2329    /// Validation:
2330    /// - `enabled` is required when any other field is set
2331    ///   (`HstsEnabledRequired`); the parser returns the typed error so
2332    ///   callers can fail fast.
2333    /// - `enabled = true && max_age = None` substitutes
2334    ///   [`DEFAULT_HSTS_MAX_AGE`].
2335    /// - `0 < max_age < 86400` warns (likely misconfig — sub-day max-age
2336    ///   is useful only for testing).
2337    /// - `preload = true` with `max_age < DEFAULT_HSTS_MAX_AGE` or
2338    ///   `include_subdomains != Some(true)` warns (the Chrome HSTS
2339    ///   preload list will reject the host).
2340    /// - `max_age = 0` is allowed silently (RFC 6797 §11.4 kill switch).
2341    pub fn to_proto(&self, scope: &str) -> Result<HstsConfig, ConfigError> {
2342        let enabled = match self.enabled {
2343            Some(v) => v,
2344            None => return Err(ConfigError::HstsEnabledRequired(scope.to_owned())),
2345        };
2346
2347        let max_age = match (enabled, self.max_age) {
2348            (true, None) => Some(DEFAULT_HSTS_MAX_AGE),
2349            (_, m) => m,
2350        };
2351
2352        if let Some(value) = max_age
2353            && value > 0
2354            && value < 86_400
2355        {
2356            warn!(
2357                "HSTS max_age = {}s on {} is below 1 day — this is almost certainly a \
2358                 misconfiguration. RFC 6797 §11.4 reserves max_age = 0 as the explicit kill \
2359                 switch.",
2360                value, scope
2361            );
2362        }
2363
2364        let include_subdomains = self.include_subdomains;
2365        let preload = self.preload;
2366
2367        if matches!(preload, Some(true)) {
2368            let max_age_value = max_age.unwrap_or(0);
2369            if max_age_value < DEFAULT_HSTS_MAX_AGE {
2370                warn!(
2371                    "HSTS preload = true on {} with max_age = {}s; the Chrome HSTS preload \
2372                     list requires max_age >= {} (https://hstspreload.org/).",
2373                    scope, max_age_value, DEFAULT_HSTS_MAX_AGE
2374                );
2375            }
2376            if include_subdomains != Some(true) {
2377                warn!(
2378                    "HSTS preload = true on {} without include_subdomains = true; the Chrome \
2379                     HSTS preload list requires includeSubDomains \
2380                     (https://hstspreload.org/).",
2381                    scope
2382                );
2383            }
2384        }
2385
2386        let config = HstsConfig {
2387            enabled: Some(enabled),
2388            max_age,
2389            include_subdomains,
2390            preload,
2391            force_replace_backend: self.force_replace_backend,
2392        };
2393
2394        // POST: a built HstsConfig always records an explicit `enabled` flag
2395        // (the `None` case errored above), and an enabled policy always carries
2396        // a max_age — defaulted to DEFAULT_HSTS_MAX_AGE when the operator left
2397        // it unset, so the worker never emits an `max-age`-less STS header.
2398        debug_assert_eq!(
2399            config.enabled,
2400            Some(enabled),
2401            "built HSTS config must record the resolved enabled flag"
2402        );
2403        debug_assert!(
2404            !enabled || config.max_age.is_some(),
2405            "an enabled HSTS policy must carry a max_age"
2406        );
2407        Ok(config)
2408    }
2409}
2410
2411impl FileClusterFrontendConfig {
2412    pub fn to_tcp_front(&self) -> Result<TcpFrontendConfig, ConfigError> {
2413        if self.path.is_some() {
2414            return Err(ConfigError::InvalidFrontendConfig(
2415                "path_prefix".to_string(),
2416            ));
2417        }
2418        if self.certificate.is_some() {
2419            return Err(ConfigError::InvalidFrontendConfig(
2420                "certificate".to_string(),
2421            ));
2422        }
2423        if self.certificate_chain.is_some() {
2424            return Err(ConfigError::InvalidFrontendConfig(
2425                "certificate_chain".to_string(),
2426            ));
2427        }
2428
2429        if self.header_predicates.is_some() {
2430            return Err(ConfigError::InvalidFrontendConfig(
2431                "header_predicates".to_string(),
2432            ));
2433        }
2434
2435        // `hostname` maps to the TCP frontend's `sni` (sozu-proxy/sozu#1279):
2436        // an SNI-scoped frontend reads the same TOML key HTTP frontends use
2437        // for their routing hostname, validated for the TCP-specific
2438        // exact-or-single-wildcard shape.
2439        let sni = match &self.hostname {
2440            Some(hostname) => Some(validate_sni_pattern(hostname)?),
2441            None => None,
2442        };
2443
2444        // `alpn` only ever gets consulted from within the SNI-scoped
2445        // preread route table (sozu-proxy/sozu#1279 hardening): a frontend
2446        // with no `hostname`/`sni` installs the worker's raw no-SNI
2447        // catch-all path instead, which never looks at `alpn` at all. Left
2448        // unchecked, this would load fine and then silently never enforce
2449        // the configured protocol list.
2450        if sni.is_none() && !self.alpn.is_empty() {
2451            return Err(ConfigError::AlpnWithoutSni {
2452                address: self.address,
2453            });
2454        }
2455
2456        let tcp_front = TcpFrontendConfig {
2457            address: self.address,
2458            tags: self.tags.clone(),
2459            sni,
2460            alpn: self.alpn.clone(),
2461            // Resolved against `known_addresses` in `populate_clusters`; a
2462            // bare `to_tcp_front` (no listener context) defaults to TCP.
2463            udp: false,
2464        };
2465        // POST: a TCP frontend binds exactly the requested address and carries
2466        // no HTTP-only attributes — the guards above reject path / certificate,
2467        // so an Ok return is a witness that none leaked through (an L7
2468        // attribute on an L4 frontend is a config-shape violation). `hostname`
2469        // is intentionally excluded from this witness: it is consumed above
2470        // into `sni`, not rejected.
2471        debug_assert_eq!(
2472            tcp_front.address, self.address,
2473            "TCP frontend must bind the requested address"
2474        );
2475        debug_assert!(
2476            self.path.is_none() && self.certificate.is_none() && self.certificate_chain.is_none(),
2477            "a built TCP frontend must carry no HTTP-only attributes"
2478        );
2479        debug_assert!(
2480            tcp_front.sni.is_some() || tcp_front.alpn.is_empty(),
2481            "a built TCP frontend without sni must never carry a non-empty alpn"
2482        );
2483        Ok(tcp_front)
2484    }
2485
2486    pub fn to_http_front(&self, _cluster_id: &str) -> Result<HttpFrontendConfig, ConfigError> {
2487        if !self.alpn.is_empty() {
2488            return Err(ConfigError::InvalidFrontendConfig("alpn".to_string()));
2489        }
2490
2491        let hostname = match &self.hostname {
2492            Some(hostname) => hostname.to_owned(),
2493            None => {
2494                return Err(ConfigError::Missing(MissingKind::Field(
2495                    "hostname".to_string(),
2496                )));
2497            }
2498        };
2499
2500        let key_opt = match self.key.as_ref() {
2501            None => None,
2502            Some(path) => {
2503                let key = Config::load_file(path)?;
2504                Some(key)
2505            }
2506        };
2507
2508        let certificate_opt = match self.certificate.as_ref() {
2509            None => None,
2510            Some(path) => {
2511                let certificate = Config::load_file(path)?;
2512                Some(certificate)
2513            }
2514        };
2515
2516        let certificate_chain = match self.certificate_chain.as_ref() {
2517            None => None,
2518            Some(path) => {
2519                let certificate_chain = Config::load_file(path)?;
2520                Some(split_certificate_chain(certificate_chain))
2521            }
2522        };
2523
2524        let path = match (self.path.as_ref(), self.path_type.as_ref()) {
2525            (None, _) => PathRule::prefix("".to_string()),
2526            (Some(s), Some(PathRuleType::Prefix)) => PathRule::prefix(s.to_string()),
2527            (Some(s), Some(PathRuleType::Regex)) => PathRule::regex(s.to_string()),
2528            (Some(s), Some(PathRuleType::Equals)) => PathRule::equals(s.to_string()),
2529            (Some(s), None) => PathRule::prefix(s.clone()),
2530        };
2531
2532        let redirect = match self.redirect.as_deref() {
2533            Some(v) => Some(parse_redirect_policy(v)?),
2534            None => None,
2535        };
2536        let redirect_scheme = match self.redirect_scheme.as_deref() {
2537            Some(v) => Some(parse_redirect_scheme(v)?),
2538            None => None,
2539        };
2540
2541        let headers = match self.headers.as_ref() {
2542            Some(entries) => {
2543                let mut out = Vec::with_capacity(entries.len());
2544                for (index, entry) in entries.iter().enumerate() {
2545                    out.push(parse_header_edit(index, entry)?);
2546                }
2547                out
2548            }
2549            None => Vec::new(),
2550        };
2551
2552        // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on
2553        // plaintext-HTTP responses. A frontend without a key+certificate
2554        // pair generates `RequestType::AddHttpFrontend` in
2555        // `HttpFrontendConfig::generate_requests`, so HSTS configured
2556        // there would silently target an HTTP frontend. Reject at
2557        // config-load before the cert-presence branch can consume it.
2558        let header_predicates = match self.header_predicates.as_ref() {
2559            Some(entries) => entries
2560                .iter()
2561                .enumerate()
2562                .map(|(index, entry)| parse_header_predicate(index, entry))
2563                .collect::<Result<Vec<_>, _>>()?,
2564            None => Vec::new(),
2565        };
2566
2567        let frontend_serves_https = key_opt.is_some() && certificate_opt.is_some();
2568        let hsts = match self.hsts.as_ref() {
2569            Some(h) => {
2570                if !frontend_serves_https {
2571                    return Err(ConfigError::HstsOnPlainHttp(format!(
2572                        "frontend {_cluster_id}/{hostname}"
2573                    )));
2574                }
2575                Some(h.to_proto(&format!("frontend {_cluster_id}/{hostname}"))?)
2576            }
2577            None => None,
2578        };
2579
2580        Ok(HttpFrontendConfig {
2581            address: self.address,
2582            hostname,
2583            certificate: certificate_opt,
2584            key: key_opt,
2585            certificate_chain,
2586            tls_versions: self.tls_versions.clone(),
2587            position: self.position,
2588            path,
2589            method: self.method.clone(),
2590            tags: self.tags.clone(),
2591            redirect,
2592            redirect_scheme,
2593            redirect_template: self.redirect_template.clone(),
2594            rewrite_host: self.rewrite_host.clone(),
2595            rewrite_path: self.rewrite_path.clone(),
2596            rewrite_port: self.rewrite_port,
2597            required_auth: self.required_auth,
2598            headers,
2599            hsts,
2600            header_predicates,
2601        })
2602    }
2603}
2604
2605/// Validates and normalizes a TCP frontend's SNI pattern
2606/// (sozu-proxy/sozu#1279): either an exact hostname or a single leading
2607/// `*.` wildcard label. Rejects an embedded `*` anywhere else (so
2608/// `*.*.example.com` and `foo.*.com` are both invalid), a bare `*`, any
2609/// empty label (leading/trailing/consecutive dots), and any non-ASCII
2610/// character ([`ConfigError::NonAsciiSniPattern`]) — on-wire SNI is always
2611/// an ASCII A-label (RFC 6066 §3 / IDNA), so a Unicode U-label would load
2612/// fine but never match at runtime, a silent routing failure. Full IDNA/
2613/// punycode transformation at config-load is intentionally out of scope
2614/// (no `idna` dependency in this crate); operators write the A-label form
2615/// directly, consistent with what the HTTP router produces via
2616/// `idna::domain_to_ascii` in `lib/src/router/mod.rs`. ASCII-lowercases
2617/// the accepted pattern for case-insensitive comparison.
2618///
2619/// Public: this is the SINGLE SNI shape validator shared by TOML config
2620/// load (this module) and the worker's request boundary
2621/// (`TcpListener::validate_new_tcp_front` in `lib/src/tcp.rs`), which an
2622/// `AddTcpFrontend` sent directly over the command socket, or a
2623/// `LoadState` replay, can reach without ever going through config-load —
2624/// both call sites must reject the identical set of malformed shapes.
2625pub fn validate_sni_pattern(sni: &str) -> Result<String, ConfigError> {
2626    let invalid = || ConfigError::InvalidSniPattern {
2627        sni: sni.to_string(),
2628    };
2629
2630    if sni.is_empty() {
2631        return Err(invalid());
2632    }
2633
2634    if !sni.is_ascii() {
2635        return Err(ConfigError::NonAsciiSniPattern {
2636            sni: sni.to_string(),
2637        });
2638    }
2639
2640    // Only a single leading "*." wildcard label is accepted; strip it (if
2641    // present) before checking the remainder is otherwise plain and
2642    // non-empty.
2643    let remainder = sni.strip_prefix("*.").unwrap_or(sni);
2644
2645    // `/` is never valid in a hostname — and the SNI route table is a
2646    // `pattern_trie::TrieNode`, whose insert treats a leftmost label wrapped
2647    // in `/.../` as a REGEX segment; letting one through would silently
2648    // widen routing beyond the documented "exact host or one leading `*.`"
2649    // contract. Checking `remainder` covers the whole pattern: the only
2650    // stripped prefix is the literal `*.`, which cannot contain `/`.
2651    if remainder.is_empty() || remainder.contains('*') || remainder.contains('/') {
2652        return Err(invalid());
2653    }
2654    if remainder.split('.').any(|label| label.is_empty()) {
2655        return Err(invalid());
2656    }
2657
2658    let normalized = sni.to_ascii_lowercase();
2659    // POST: the normalized pattern carries exactly one '*' (the leading
2660    // wildcard marker) or none at all — never more, since the checks above
2661    // reject any '*' in the remainder — and is pure ASCII, since non-ASCII
2662    // input was rejected before normalization (a non-ASCII pattern can
2663    // never match the ASCII A-label SNI on the wire).
2664    debug_assert!(
2665        normalized.matches('*').count() <= 1,
2666        "a validated SNI pattern must carry at most one wildcard marker"
2667    );
2668    debug_assert!(
2669        normalized.is_ascii(),
2670        "a validated SNI pattern must be pure ASCII"
2671    );
2672    Ok(normalized)
2673}
2674
2675/// Parse a `redirect` TOML value (case-insensitive) into the proto enum.
2676pub(crate) fn parse_redirect_policy(value: &str) -> Result<RedirectPolicy, ConfigError> {
2677    match value.to_ascii_lowercase().as_str() {
2678        "forward" => Ok(RedirectPolicy::Forward),
2679        "permanent" => Ok(RedirectPolicy::Permanent),
2680        "unauthorized" => Ok(RedirectPolicy::Unauthorized),
2681        "silent_close" => Ok(RedirectPolicy::SilentClose),
2682        _ => Err(ConfigError::InvalidRedirectPolicy(value.to_owned())),
2683    }
2684}
2685
2686/// Parse a `redirect_scheme` TOML value (case-insensitive) into the proto enum.
2687pub(crate) fn parse_redirect_scheme(value: &str) -> Result<RedirectScheme, ConfigError> {
2688    match value.to_ascii_lowercase().as_str() {
2689        "use-same" | "use_same" => Ok(RedirectScheme::UseSame),
2690        "use-http" | "use_http" => Ok(RedirectScheme::UseHttp),
2691        "use-https" | "use_https" => Ok(RedirectScheme::UseHttps),
2692        _ => Err(ConfigError::InvalidRedirectScheme(value.to_owned())),
2693    }
2694}
2695
2696/// Parse a `[[clusters.<id>.frontends.headers]]` entry into the proto
2697/// [`Header`] message. `index` is the zero-based position of `entry` in
2698/// the source array — surfaced into the error so a multi-entry config
2699/// pinpoints the bad row instead of just naming the unknown position.
2700/// An empty `value` is the HAProxy `del-header` parity (deletes the
2701/// header by name); the proto carries the empty string verbatim.
2702pub(crate) fn parse_header_edit(
2703    index: usize,
2704    entry: &HeaderEditConfig,
2705) -> Result<Header, ConfigError> {
2706    let position = match entry.position.to_ascii_lowercase().as_str() {
2707        "request" => HeaderPosition::Request,
2708        "response" => HeaderPosition::Response,
2709        "both" => HeaderPosition::Both,
2710        _ => {
2711            return Err(ConfigError::InvalidHeaderPosition {
2712                index,
2713                position: entry.position.clone(),
2714            });
2715        }
2716    };
2717    if !header_name_is_valid_token(entry.key.as_bytes()) {
2718        return Err(ConfigError::InvalidHeaderBytes {
2719            index,
2720            field: "key",
2721        });
2722    }
2723    if header_value_contains_forbidden_controls(entry.value.as_bytes()) {
2724        return Err(ConfigError::InvalidHeaderBytes {
2725            index,
2726            field: "value",
2727        });
2728    }
2729    let header = Header {
2730        position: position as i32,
2731        key: entry.key.clone(),
2732        val: entry.value.clone(),
2733    };
2734    // POST: a Header that escapes this function carries a key that is a valid
2735    // RFC 9110 token and a value free of the forbidden control bytes — the two
2736    // guards above are the sole gate, so an emitted Header can never inject a
2737    // CRLF or a bad token onto the H1 wire (mirrors the runtime filter in
2738    // mux/converter.rs).
2739    debug_assert!(
2740        header_name_is_valid_token(header.key.as_bytes()),
2741        "an emitted header key must be a valid token"
2742    );
2743    debug_assert!(
2744        !header_value_contains_forbidden_controls(header.val.as_bytes()),
2745        "an emitted header value must be free of forbidden control bytes"
2746    );
2747    Ok(header)
2748}
2749
2750/// Parse one `header_predicates` entry of a frontend into the proto
2751/// [`HeaderPredicate`] (sozu-proxy/sozu#1253), then hold it to
2752/// [`validate_header_predicate`].
2753pub(crate) fn parse_header_predicate(
2754    index: usize,
2755    entry: &HeaderPredicateConfig,
2756) -> Result<HeaderPredicate, ConfigError> {
2757    let kind = match entry.kind.to_ascii_lowercase().as_str() {
2758        "exact" => HeaderMatchKind::Exact,
2759        "regex" => HeaderMatchKind::Regex,
2760        "present" => HeaderMatchKind::Present,
2761        _ => {
2762            return Err(ConfigError::InvalidHeaderPredicate {
2763                index,
2764                reason: "match must be \"exact\", \"regex\" or \"present\"",
2765            });
2766        }
2767    };
2768    let predicate = HeaderPredicate {
2769        name: entry.name.to_owned(),
2770        kind: kind as i32,
2771        value: entry.value.to_owned(),
2772    };
2773    validate_header_predicate(&predicate)
2774        .map_err(|reason| ConfigError::InvalidHeaderPredicate { index, reason })?;
2775    Ok(predicate)
2776}
2777
2778/// Validate one frontend header predicate (sozu-proxy/sozu#1253).
2779///
2780/// The name must be an RFC 9110 `token`, and neither `Host` nor `Cookie`:
2781/// like an `affinity_header` ([`validate_affinity_key`]), a predicate on
2782/// either could never find its header among the request's — H1 folds `Host`
2783/// into the request authority (H2 sends `:authority`) and both parsers move
2784/// cookies into a jar — so it would silently never match. `exact` and `regex`
2785/// need a `value`, `present` refuses one, and an `exact` value carrying a
2786/// control byte no parsed header value can hold is refused rather than left
2787/// to never match. A `regex` value's grammar is the router's to check, where
2788/// it is compiled.
2789///
2790/// Called at config load and by the worker's router on every frontend
2791/// addition, which the main process also runs before accepting one
2792/// (`validate_frontend_request` in `bin/src/command/requests.rs`), so a TOML
2793/// load, a saved state and a direct API request are held to the same rule.
2794pub fn validate_header_predicate(predicate: &HeaderPredicate) -> Result<(), &'static str> {
2795    let name = predicate.name.as_str();
2796    if !header_name_is_valid_token(name.as_bytes()) {
2797        return Err("name must be a non-empty RFC 9110 token (a header field name)");
2798    }
2799    if name.eq_ignore_ascii_case("host") {
2800        return Err(
2801            "name cannot be Host: the request authority is not a header Sōzu keeps \
2802             (H1 folds Host into it, H2 sends :authority); route on the hostname instead",
2803        );
2804    }
2805    if name.eq_ignore_ascii_case("cookie") {
2806        return Err("name cannot be Cookie: cookies are parsed into a jar, not kept as a header");
2807    }
2808    match (
2809        HeaderMatchKind::try_from(predicate.kind),
2810        predicate.value.as_deref(),
2811    ) {
2812        (Err(_), _) => Err("unknown match kind"),
2813        (Ok(HeaderMatchKind::Present), Some(_)) => Err("a present predicate takes no value"),
2814        (Ok(HeaderMatchKind::Present), None) => Ok(()),
2815        (Ok(HeaderMatchKind::Exact | HeaderMatchKind::Regex), None) => {
2816            Err("an exact or regex predicate requires a value")
2817        }
2818        (Ok(HeaderMatchKind::Exact), Some(value))
2819            if header_value_contains_forbidden_controls(value.as_bytes()) =>
2820        {
2821            Err("an exact value cannot carry a control character no header value can hold")
2822        }
2823        (Ok(HeaderMatchKind::Exact | HeaderMatchKind::Regex), Some(_)) => Ok(()),
2824    }
2825}
2826
2827/// Field names follow the RFC 9110 §5.1 `token` grammar: non-empty,
2828/// composed of `tchar` bytes (alphanumeric plus a closed punctuation
2829/// list). HTAB and SP are NOT tchar — they belong to field-value
2830/// grammar and must be rejected in the name. Reusing the more
2831/// permissive value-side filter would let `Host\t` slip through and
2832/// produce an invalid header line on the H1 wire (security review
2833/// LISA-002 follow-up).
2834pub(crate) fn header_name_is_valid_token(bytes: &[u8]) -> bool {
2835    if bytes.is_empty() {
2836        return false;
2837    }
2838    bytes.iter().all(|&b| is_tchar(b))
2839}
2840
2841/// `tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
2842/// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA` per RFC 9110 §5.6.2.
2843fn is_tchar(b: u8) -> bool {
2844    b.is_ascii_alphanumeric()
2845        || matches!(
2846            b,
2847            b'!' | b'#'
2848                | b'$'
2849                | b'%'
2850                | b'&'
2851                | b'\''
2852                | b'*'
2853                | b'+'
2854                | b'-'
2855                | b'.'
2856                | b'^'
2857                | b'_'
2858                | b'`'
2859                | b'|'
2860                | b'~'
2861        )
2862}
2863
2864/// Reject any byte that would let a header injection escape the value
2865/// block on the wire (RFC 9110 §5.5 / RFC 9113 §8.2.1):
2866/// `\0..=\x08`, `\x0A..=\x1F`, and `\x7F` — the entire C0 control set
2867/// minus horizontal tab `\x09`, which RFC 9110 explicitly permits in
2868/// field values. Mirrors the runtime filter at
2869/// `lib/src/protocol/mux/converter.rs::call` so config-load and runtime
2870/// agree on which header values may travel.
2871pub(crate) fn header_value_contains_forbidden_controls(bytes: &[u8]) -> bool {
2872    bytes
2873        .iter()
2874        .any(|&b| matches!(b, 0x00..=0x08 | 0x0A..=0x1F | 0x7F))
2875}
2876
2877#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2878#[serde(deny_unknown_fields, rename_all = "lowercase")]
2879pub enum ListenerProtocol {
2880    Http,
2881    Https,
2882    Tcp,
2883    Udp,
2884}
2885
2886#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2887#[serde(deny_unknown_fields, rename_all = "lowercase")]
2888pub enum FileClusterProtocolConfig {
2889    Http,
2890    Tcp,
2891}
2892
2893fn default_health_check_interval() -> u32 {
2894    10
2895}
2896fn default_health_check_timeout() -> u32 {
2897    5
2898}
2899fn default_health_check_threshold() -> u32 {
2900    3
2901}
2902fn default_health_check_uri() -> String {
2903    "/".to_owned()
2904}
2905
2906#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2907#[serde(deny_unknown_fields)]
2908pub struct FileHealthCheckConfig {
2909    /// Request target of the HTTP probe. Defaults to `/`; ignored in `TCP`
2910    /// mode.
2911    #[serde(default = "default_health_check_uri")]
2912    pub uri: String,
2913    #[serde(default = "default_health_check_interval")]
2914    pub interval: u32,
2915    #[serde(default = "default_health_check_timeout")]
2916    pub timeout: u32,
2917    #[serde(default = "default_health_check_threshold")]
2918    pub healthy_threshold: u32,
2919    #[serde(default = "default_health_check_threshold")]
2920    pub unhealthy_threshold: u32,
2921    #[serde(default)]
2922    pub expected_status: u32,
2923    /// Probe mode, parsed in SCREAMING_SNAKE_CASE: `"HTTP"` (the default)
2924    /// or `"TCP"`.
2925    #[serde(default)]
2926    pub mode: Option<HealthCheckMode>,
2927    /// HTTP mode: statuses that count as healthy, each one of `"200"`,
2928    /// `"200-399"`, `"2xx"` or `"any"` (see [`parse_http_status_range`]).
2929    #[serde(default)]
2930    pub accepted_statuses: Vec<String>,
2931    /// Port the probe dials on the backend's IP address, in `1..=65535`.
2932    /// Unset probes the backend's data port.
2933    #[serde(default)]
2934    pub port: Option<u32>,
2935    /// Extra request header fields of the HTTP probe, as an ordered array of
2936    /// `{ name, value_file }` tables. A value is never written in the
2937    /// configuration: `value_file` names the file the value is read from when
2938    /// the probe is set up, and parsing never opens it.
2939    #[serde(default)]
2940    pub headers: Vec<FileHealthCheckHeader>,
2941}
2942
2943/// One `[[clusters.<id>.health_check.headers]]` entry: a header name and the
2944/// file holding its value. Also the reference kept by [`HttpClusterConfig`]
2945/// and [`TcpClusterConfig`], so a path is stored exactly as written; resolving
2946/// a relative one is left to the code that reads the file.
2947#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2948#[serde(deny_unknown_fields)]
2949pub struct FileHealthCheckHeader {
2950    pub name: String,
2951    pub value_file: String,
2952}
2953
2954impl std::fmt::Debug for FileHealthCheckHeader {
2955    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2956        f.debug_struct("FileHealthCheckHeader")
2957            .field("name_len", &self.name.len())
2958            .field("value_file", &"<redacted>")
2959            .finish_non_exhaustive()
2960    }
2961}
2962
2963impl FileHealthCheckConfig {
2964    /// Build the proto config, parsing `accepted_statuses` and running
2965    /// [`validate_health_check_config`] so a bad block is refused at load
2966    /// time with the cluster it belongs to.
2967    pub fn to_proto(&self) -> Result<HealthCheckConfig, &'static str> {
2968        // A proto config names its headers without a value, so a probe built
2969        // from it alone could never fill them in: keep the references too, via
2970        // [`Self::to_parts`].
2971        if !self.headers.is_empty() {
2972            return Err(
2973                "health check headers from the configuration file are not available yet: their values need the protected channel",
2974            );
2975        }
2976        self.to_parts().map(|(config, _)| config)
2977    }
2978
2979    /// The health check as metadata plus the ordered `value_file` references
2980    /// of its headers. The metadata names the headers and leaves every
2981    /// fingerprint empty; names are validated, the files are not touched.
2982    pub fn to_parts(
2983        &self,
2984    ) -> Result<(HealthCheckConfig, Vec<FileHealthCheckHeader>), &'static str> {
2985        let accepted_statuses = self
2986            .accepted_statuses
2987            .iter()
2988            .map(|spec| parse_http_status_range(spec))
2989            .collect::<Result<Vec<_>, _>>()?;
2990        let proto = HealthCheckConfig {
2991            uri: self.uri.to_owned(),
2992            interval: self.interval,
2993            timeout: self.timeout,
2994            healthy_threshold: self.healthy_threshold,
2995            unhealthy_threshold: self.unhealthy_threshold,
2996            expected_status: self.expected_status,
2997            mode: self.mode.unwrap_or(HealthCheckMode::Http) as i32,
2998            accepted_statuses,
2999            port: self.port,
3000            headers: self
3001                .headers
3002                .iter()
3003                .map(|header| HealthCheckHeader {
3004                    name: header.name.to_owned(),
3005                    fingerprint: String::new(),
3006                })
3007                .collect(),
3008        };
3009        // POST: the proto mirrors the file config exactly — the URI and all
3010        // timing knobs are carried through verbatim (no clamping or defaulting
3011        // happens at this layer; defaults are applied by serde at parse time).
3012        debug_assert_eq!(proto.uri, self.uri, "proto URI must mirror the file config");
3013        debug_assert!(
3014            proto.interval == self.interval
3015                && proto.timeout == self.timeout
3016                && proto.healthy_threshold == self.healthy_threshold
3017                && proto.unhealthy_threshold == self.unhealthy_threshold,
3018            "proto timing knobs must mirror the file config"
3019        );
3020        debug_assert_eq!(
3021            proto.port, self.port,
3022            "proto probe port must mirror the file config"
3023        );
3024        debug_assert_eq!(
3025            proto.accepted_statuses.len(),
3026            self.accepted_statuses.len(),
3027            "every accepted status spec must map to exactly one range"
3028        );
3029        debug_assert_eq!(
3030            proto.headers.len(),
3031            self.headers.len(),
3032            "every configured header must map to exactly one proto header"
3033        );
3034        validate_health_check_config(&proto)?;
3035        Ok((proto, self.headers.clone()))
3036    }
3037}
3038
3039/// Lowest and highest valid HTTP status codes: RFC 9110 §15 calls values
3040/// outside `100..=599` invalid.
3041pub const HTTP_STATUS_MIN: u32 = 100;
3042pub const HTTP_STATUS_MAX: u32 = 599;
3043
3044/// Parse one `accepted_statuses` entry of the TOML file or the CLI:
3045///
3046/// - `"any"`: every valid status, `100-599` — any well-formed HTTP response;
3047/// - `"Nxx"` with `N` in `1..=5`: the class `N00-N99`;
3048/// - `"A-B"`: the inclusive range `A..=B`;
3049/// - `"A"`: the single status `A`.
3050///
3051/// Bounds must lie in `100..=599` and `A <= B`.
3052pub fn parse_http_status_range(spec: &str) -> Result<HttpStatusRange, &'static str> {
3053    const INVALID: &str = "health check accepted status must be a code (\"404\"), a range \
3054                           (\"200-399\"), a class (\"2xx\") or \"any\"";
3055    let spec = spec.trim();
3056    let range = if spec.eq_ignore_ascii_case("any") {
3057        HttpStatusRange {
3058            start: HTTP_STATUS_MIN,
3059            end: HTTP_STATUS_MAX,
3060        }
3061    } else if let [class @ b'1'..=b'5', x1, x2] = spec.as_bytes()
3062        && x1.eq_ignore_ascii_case(&b'x')
3063        && x2.eq_ignore_ascii_case(&b'x')
3064    {
3065        let start = u32::from(class - b'0') * 100;
3066        HttpStatusRange {
3067            start,
3068            end: start + 99,
3069        }
3070    } else if let Some((start, end)) = spec.split_once('-') {
3071        HttpStatusRange {
3072            start: parse_status_code(start).ok_or(INVALID)?,
3073            end: parse_status_code(end).ok_or(INVALID)?,
3074        }
3075    } else {
3076        let code = parse_status_code(spec).ok_or(INVALID)?;
3077        HttpStatusRange {
3078            start: code,
3079            end: code,
3080        }
3081    };
3082    validate_http_status_range(&range)?;
3083    Ok(range)
3084}
3085
3086/// A status code is ASCII digits only: `u32::from_str` alone would also take
3087/// a leading `+`.
3088fn parse_status_code(code: &str) -> Option<u32> {
3089    let code = code.trim();
3090    if code.is_empty() || !code.bytes().all(|b| b.is_ascii_digit()) {
3091        return None;
3092    }
3093    code.parse().ok()
3094}
3095
3096fn validate_http_status_range(range: &HttpStatusRange) -> Result<(), &'static str> {
3097    if range.start < HTTP_STATUS_MIN || range.end > HTTP_STATUS_MAX {
3098        return Err("health check accepted statuses must lie within 100-599");
3099    }
3100    if range.start > range.end {
3101        return Err("health check accepted status range must not end before it starts");
3102    }
3103    debug_assert!(
3104        (HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&range.start)
3105            && (HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&range.end),
3106        "a validated range must lie within the valid status codes"
3107    );
3108    Ok(())
3109}
3110
3111/// Validate a cluster's client affinity key source, `affinity_header` or
3112/// `affinity_cookie`.
3113///
3114/// At most one of the two may be set, and the one that is must be an RFC 9110
3115/// `token` (a cookie name is a `token` too, RFC 6265 §4.1.1): a name no
3116/// request can carry would silently key every request on its source IP.
3117///
3118/// Two header names are refused although they are tokens, because the key
3119/// lookup can never find them among the request's headers: `Host`, which the
3120/// H1 parser folds into the request authority and elides (H2 carries it as
3121/// `:authority`), and `Cookie`, whose crumbs both parsers move into the cookie
3122/// jar — key on one cookie with `affinity_cookie` instead. Names compare
3123/// case-insensitively, as header names do.
3124///
3125/// Called on every `AddCluster`, so a TOML load, a reload, a saved state and
3126/// a direct API request are held to the same rule as `sozu cluster add`.
3127pub fn validate_affinity_key(
3128    affinity_header: Option<&str>,
3129    affinity_cookie: Option<&str>,
3130) -> Result<(), &'static str> {
3131    match (affinity_header, affinity_cookie) {
3132        (Some(_), Some(_)) => {
3133            Err("affinity_header and affinity_cookie are mutually exclusive: set at most one")
3134        }
3135        (Some(name), None) if !header_name_is_valid_token(name.as_bytes()) => {
3136            Err("affinity_header must be a non-empty RFC 9110 token (a header field name)")
3137        }
3138        (Some(name), None) if name.eq_ignore_ascii_case("host") => Err(
3139            "affinity_header cannot be Host: the request authority is not a header Sōzu keeps \
3140             (H1 folds Host into it, H2 sends :authority)",
3141        ),
3142        (Some(name), None) if name.eq_ignore_ascii_case("cookie") => Err(
3143            "affinity_header cannot be Cookie: cookies are parsed into a jar; key on one with \
3144             affinity_cookie instead",
3145        ),
3146        (None, Some(name)) if !header_name_is_valid_token(name.as_bytes()) => {
3147            Err("affinity_cookie must be a non-empty RFC 6265 cookie name (an RFC 9110 token)")
3148        }
3149        _ => Ok(()),
3150    }
3151}
3152
3153/// Default `shard_min_backends`: the reference fleet of *Workload isolation
3154/// using shuffle sharding* (Colm MacCárthaigh, Amazon Builders' Library,
3155/// 2019), eight workers in shards of two. Below it the combinations are too
3156/// few to isolate anything: with four backends in shards of two, six shards
3157/// exist and nearly every pair of clients overlaps.
3158pub const DEFAULT_SHARD_MIN_BACKENDS: u32 = 8;
3159
3160/// Validate a cluster's shuffle-sharding knobs (sozu-proxy/sozu#524).
3161///
3162/// `shard_percent` turns sharding on and must lie in `1..=100`;
3163/// `shard_min_backends` must be at least 2, the smallest shard; and
3164/// `shard_min_backends` or `shard_mode` without `shard_percent` is refused
3165/// rather than silently inert. Called at config load and on every
3166/// `AddCluster`.
3167pub fn validate_shuffle_sharding(
3168    shard_percent: Option<u32>,
3169    shard_min_backends: Option<u32>,
3170    shard_mode: Option<i32>,
3171) -> Result<(), &'static str> {
3172    let Some(percent) = shard_percent else {
3173        if shard_min_backends.is_some() || shard_mode.is_some() {
3174            return Err("shard_min_backends and shard_mode require shard_percent");
3175        }
3176        return Ok(());
3177    };
3178    if !(1..=100).contains(&percent) {
3179        return Err("shard_percent must lie in 1..=100");
3180    }
3181    if shard_min_backends.is_some_and(|minimum| minimum < 2) {
3182        return Err("shard_min_backends must be at least 2, the smallest shard");
3183    }
3184    if shard_mode.is_some_and(|mode| ShardMode::try_from(mode).is_err()) {
3185        return Err("shard_mode must be FALLBACK or STRICT");
3186    }
3187    Ok(())
3188}
3189
3190/// Most custom request headers a health check may carry.
3191pub const HEALTH_CHECK_MAX_HEADERS: usize = 32;
3192/// Most `name + value + 4` bytes (the `: ` and CRLF an HTTP/1.1 line adds)
3193/// the custom headers of a health check may sum to.
3194pub const HEALTH_CHECK_MAX_HEADER_BYTES: usize = 8192;
3195/// Largest HPACK block the h2c probe may send: one HEADERS frame, which the
3196/// probe never splits, whose payload stays within the default
3197/// `SETTINGS_MAX_FRAME_SIZE` of 16384 bytes (RFC 9113 §6.5.2).
3198pub const HEALTH_CHECK_MAX_HPACK_BLOCK: usize = 16384;
3199/// Longest `:authority` the h2c probe can name: a [`std::net::SocketAddr`]
3200/// `Display` is at most `[` + 39 IPv6 digits + `%4294967295` + `]` + `:65535`.
3201pub const HEALTH_CHECK_MAX_AUTHORITY_LEN: usize = 1 + 39 + 11 + 1 + 6;
3202
3203/// Header names the probe owns or that would change the connection framing.
3204const HEALTH_CHECK_FORBIDDEN_HEADERS: [&str; 10] = [
3205    "host",
3206    "connection",
3207    "keep-alive",
3208    "proxy-connection",
3209    "transfer-encoding",
3210    "content-length",
3211    "te",
3212    "trailer",
3213    "upgrade",
3214    "expect",
3215];
3216
3217/// Bytes of an HPACK integer with a 7-bit prefix (RFC 7541 §5.1).
3218fn hpack_string_length_bytes(length: usize) -> usize {
3219    if length < 127 {
3220        return 1;
3221    }
3222    let mut rest = length - 127;
3223    let mut bytes = 1;
3224    loop {
3225        bytes += 1;
3226        rest >>= 7;
3227        if rest == 0 {
3228            return bytes;
3229        }
3230    }
3231}
3232
3233/// Upper bound of the HPACK block the h2c probe encodes for `cfg`: the
3234/// four pseudo-headers with the longest possible `:authority`, then every
3235/// custom field as a literal with a new name (flag byte, the two string
3236/// lengths, name, value), none Huffman coded.
3237pub fn health_check_hpack_upper_bound(cfg: &HealthCheckConfig, value_lens: &[usize]) -> usize {
3238    // `:method GET` and `:scheme http` are static-table entries, one byte each.
3239    let mut bound = 2;
3240    // `:path`: an indexed name, or the whole `/` entry, plus the literal URI.
3241    bound += 1 + hpack_string_length_bytes(cfg.uri.len()) + cfg.uri.len();
3242    // `:authority`: an indexed name plus the literal address.
3243    bound += 1
3244        + hpack_string_length_bytes(HEALTH_CHECK_MAX_AUTHORITY_LEN)
3245        + HEALTH_CHECK_MAX_AUTHORITY_LEN;
3246    for (header, value_len) in cfg.headers.iter().zip(value_lens) {
3247        bound += 1
3248            + hpack_string_length_bytes(header.name.len())
3249            + header.name.len()
3250            + hpack_string_length_bytes(*value_len)
3251            + value_len;
3252    }
3253    bound
3254}
3255
3256/// The per-value rules of a health-check header: no forbidden control byte,
3257/// and no leading or trailing SP or HTAB. Single source for
3258/// [`validate_health_check_headers`] and for the protected ingress value
3259/// (`crate::secret::SecretValue`); the diagnostics are static, they never
3260/// echo the value.
3261pub(crate) fn check_health_check_header_value(value: &str) -> Result<(), &'static str> {
3262    if header_value_contains_forbidden_controls(value.as_bytes()) {
3263        return Err("health check header value must not contain control bytes");
3264    }
3265    // RFC 9113 §8.2.1: a field value must not begin or end with SP or HTAB
3266    // (an HTTP/2 peer treats such a request as malformed). Protobuf and TOML
3267    // values keep their exact bytes, so they are refused instead of being
3268    // silently rewritten.
3269    if value.starts_with([' ', '\t']) || value.ends_with([' ', '\t']) {
3270        return Err("health check header value must not begin or end with a space or a tab");
3271    }
3272    Ok(())
3273}
3274
3275/// Validate the names of the custom `headers` of a health check, which is all
3276/// a configuration holds of them. Diagnostics are static: they never echo a
3277/// configured name.
3278fn validate_health_check_headers(
3279    cfg: &HealthCheckConfig,
3280    mode: HealthCheckMode,
3281) -> Result<(), &'static str> {
3282    if cfg.headers.is_empty() {
3283        return Ok(());
3284    }
3285    if mode == HealthCheckMode::Tcp {
3286        return Err("health check headers apply to HTTP mode only: leave them unset with mode TCP");
3287    }
3288    if cfg.headers.len() > HEALTH_CHECK_MAX_HEADERS {
3289        return Err("health check carries more than 32 headers");
3290    }
3291    for (index, header) in cfg.headers.iter().enumerate() {
3292        if header.name.starts_with(':')
3293            || HEALTH_CHECK_FORBIDDEN_HEADERS
3294                .iter()
3295                .any(|forbidden| header.name.eq_ignore_ascii_case(forbidden))
3296        {
3297            return Err("health check header name is reserved for the probe or its framing");
3298        }
3299        if !header_name_is_valid_token(header.name.as_bytes()) {
3300            return Err("health check header name must be a non-empty RFC 9110 token");
3301        }
3302        if cfg.headers[..index]
3303            .iter()
3304            .any(|earlier| earlier.name.eq_ignore_ascii_case(&header.name))
3305        {
3306            return Err("health check header names must be unique, compared case-insensitively");
3307        }
3308        // Empty is an operator order the main process has yet to fingerprint;
3309        // anything else must be a fingerprint, or it is not metadata at all.
3310        if !header.fingerprint.is_empty()
3311            && header
3312                .fingerprint
3313                .parse::<crate::secret::HeaderFingerprint>()
3314                .is_err()
3315        {
3316            return Err("health check header fingerprint is malformed");
3317        }
3318    }
3319    Ok(())
3320}
3321
3322/// Validate the clear `values` of the custom headers of `cfg`, whose names
3323/// [`validate_health_check_config`] validates: one value per header in the
3324/// same order and under the same name, each meeting the per-value rules,
3325/// within the 8192-byte wire sum and the one-frame HTTP/2 block. Diagnostics
3326/// are static: they never echo a name or a value, nor any length of a value.
3327pub fn validate_health_check_header_values(
3328    cfg: &HealthCheckConfig,
3329    values: &[HealthCheckHeaderValue],
3330) -> Result<(), &'static str> {
3331    if values.len() != cfg.headers.len() {
3332        return Err("health check carries a header value count different from its header count");
3333    }
3334    let mut wire_bytes = 0usize;
3335    for (header, carrier) in cfg.headers.iter().zip(values) {
3336        if header.name != carrier.name {
3337            return Err(
3338                "health check header values must follow the order and names of the headers",
3339            );
3340        }
3341        let text = std::str::from_utf8(&carrier.value)
3342            .map_err(|_| "health check header value must be valid UTF-8")?;
3343        check_health_check_header_value(text)?;
3344        wire_bytes = wire_bytes
3345            .saturating_add(header.name.len())
3346            .saturating_add(carrier.value.len())
3347            .saturating_add(4);
3348    }
3349    if wire_bytes > HEALTH_CHECK_MAX_HEADER_BYTES {
3350        return Err("health check headers exceed 8192 bytes (name + value + 4 per header)");
3351    }
3352    let value_lens: Vec<usize> = values.iter().map(|carrier| carrier.value.len()).collect();
3353    if health_check_hpack_upper_bound(cfg, &value_lens) > HEALTH_CHECK_MAX_HPACK_BLOCK {
3354        return Err("health check URI and headers may exceed the 16384-byte HTTP/2 probe block");
3355    }
3356    Ok(())
3357}
3358
3359/// Validate a [`HealthCheckConfig`] for the rules every layer relies on:
3360/// a known probe mode, strict positive thresholds, a URI that cannot
3361/// smuggle a second HTTP message on the wire (RFC 9110 §5.1 —
3362/// request-target; the leading `/` is only required in HTTP mode, the
3363/// only one that sends it), accepted status ranges within `100..=599`,
3364/// at most one of `expected_status` / `accepted_statuses`, neither in
3365/// TCP mode, which judges no status, and a probe `port`, when set, within
3366/// `1..=65535`. Used by
3367/// the CLI request builder and the worker `SetHealthCheck` handler so
3368/// off-channel inputs (TOML reload, third-party clients) are
3369/// constrained the same way as `sozu cluster health-check set`.
3370///
3371/// The function is intentionally [`Result<(), &'static str>`] rather
3372/// than carrying a structured error: the diagnostics only flow into
3373/// CLI output / worker error responses where the message is the value.
3374pub fn validate_health_check_config(cfg: &HealthCheckConfig) -> Result<(), &'static str> {
3375    let Ok(mode) = HealthCheckMode::try_from(cfg.mode) else {
3376        return Err("health check mode must be HTTP or TCP");
3377    };
3378    if cfg.interval == 0 {
3379        return Err("health check interval must be > 0");
3380    }
3381    if cfg.timeout == 0 {
3382        return Err("health check timeout must be > 0");
3383    }
3384    if cfg.healthy_threshold == 0 {
3385        return Err("health check healthy_threshold must be > 0");
3386    }
3387    if cfg.unhealthy_threshold == 0 {
3388        return Err("health check unhealthy_threshold must be > 0");
3389    }
3390    // The URI only reaches the wire in HTTP mode; TCP mode ignores it and
3391    // may leave it empty. The control-byte check below still applies, so a
3392    // stored config never carries a URI that would smuggle a second message
3393    // if displayed or later reused.
3394    if mode == HealthCheckMode::Http && !cfg.uri.starts_with('/') {
3395        return Err("health check URI must start with '/'");
3396    }
3397    if cfg
3398        .uri
3399        .bytes()
3400        .any(|b| b == b'\r' || b == b'\n' || b == 0 || (b < 0x20 && b != b'\t'))
3401    {
3402        return Err("health check URI must not contain CR, LF, NUL, or other C0 control bytes");
3403    }
3404    for range in &cfg.accepted_statuses {
3405        validate_http_status_range(range)?;
3406    }
3407    if cfg.expected_status != 0 && !cfg.accepted_statuses.is_empty() {
3408        return Err(
3409            "health check expected_status and accepted_statuses are mutually exclusive: \
3410             set at most one",
3411        );
3412    }
3413    // A TCP probe judges no status: a status field set beside it would read
3414    // as a constraint the probe silently does not apply.
3415    if mode == HealthCheckMode::Tcp
3416        && (cfg.expected_status != 0 || !cfg.accepted_statuses.is_empty())
3417    {
3418        return Err(
3419            "health check expected_status and accepted_statuses apply to HTTP mode only: \
3420             leave them unset with mode TCP",
3421        );
3422    }
3423    // The probe dials `ip:port`: 0 names no port, and a TCP port is 16 bits.
3424    if cfg
3425        .port
3426        .is_some_and(|port| !(1..=u32::from(u16::MAX)).contains(&port))
3427    {
3428        return Err("health check port must lie in 1..=65535");
3429    }
3430    validate_health_check_headers(cfg, mode)?;
3431    // POST: a validated config has strictly-positive timing knobs (a zero
3432    // interval/timeout/threshold would make the health-check loop spin or
3433    // never converge) and a request-target the worker can splice into an HTTP
3434    // probe without smuggling a second message. Every Ok return is a witness
3435    // for all of these.
3436    debug_assert!(
3437        cfg.interval > 0
3438            && cfg.timeout > 0
3439            && cfg.healthy_threshold > 0
3440            && cfg.unhealthy_threshold > 0,
3441        "validated health-check thresholds must all be strictly positive"
3442    );
3443    debug_assert!(
3444        mode == HealthCheckMode::Tcp || cfg.uri.starts_with('/'),
3445        "a validated HTTP health-check URI must be an absolute path"
3446    );
3447    debug_assert!(
3448        cfg.expected_status == 0 || cfg.accepted_statuses.is_empty(),
3449        "a validated health check sets at most one of expected_status and accepted_statuses"
3450    );
3451    debug_assert!(
3452        mode == HealthCheckMode::Http
3453            || (cfg.expected_status == 0 && cfg.accepted_statuses.is_empty()),
3454        "a validated TCP health check sets no status field"
3455    );
3456    debug_assert!(
3457        cfg.port
3458            .is_none_or(|port| port != 0 && u16::try_from(port).is_ok()),
3459        "a validated health-check probe port is a non-zero 16-bit port"
3460    );
3461    Ok(())
3462}
3463
3464#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3465#[serde(deny_unknown_fields)]
3466pub struct FileClusterConfig {
3467    pub frontends: Vec<FileClusterFrontendConfig>,
3468    pub backends: Vec<FileBackendConfig>,
3469    pub protocol: FileClusterProtocolConfig,
3470    pub sticky_session: Option<bool>,
3471    pub https_redirect: Option<bool>,
3472    #[serde(default)]
3473    pub send_proxy: Option<bool>,
3474    #[serde(default)]
3475    pub load_balancing: LoadBalancingAlgorithms,
3476    pub answer_503: Option<String>,
3477    #[serde(default)]
3478    pub load_metric: Option<LoadMetric>,
3479    /// Backend-capability hint: `true` when the backend speaks HTTP/2 (h2c or h2+TLS once #1218 lands).
3480    /// Does NOT gate H2 at the frontend — frontend H2 is ALPN-negotiated independently (see `alpn_protocols`).
3481    pub http2: Option<bool>,
3482    /// Per-cluster HTTP answer template overrides keyed by HTTP status
3483    /// code (e.g. `"503"`). Each value is either a filesystem path or an
3484    /// `inline:<body>` literal — see [`resolve_answer_source`]. Loaded
3485    /// into [`Cluster::answers`] at build time via [`load_answers`].
3486    ///
3487    /// Layering: an entry here overrides the listener-level
3488    /// `[listeners.<id>.answers]` default for the matching status on
3489    /// requests routed to this cluster. The listener-level map is the
3490    /// global default; the cluster-level map is the per-cluster
3491    /// override.
3492    pub answers: Option<BTreeMap<String, String>>,
3493    /// Optional explicit port to use when building the `Location` header
3494    /// for an `https_redirect`. When unset, the listener's effective HTTPS
3495    /// port is used. Lets operators front a non-standard HTTPS port (e.g.
3496    /// 8443) on the redirect target while keeping `https_redirect = true`.
3497    pub https_redirect_port: Option<u32>,
3498    /// Authorized credentials for HTTP basic authentication, formatted as
3499    /// `username:hex(sha256(password))` (lower-case hex). Empty list
3500    /// disables auth even when a frontend sets `required_auth = true` —
3501    /// such requests are rejected with 401.
3502    pub authorized_hashes: Option<Vec<String>>,
3503    /// Realm string emitted in `WWW-Authenticate: Basic realm="…"` when
3504    /// an unauthenticated request is rejected. Treated as an opaque
3505    /// value (no template substitution).
3506    pub www_authenticate: Option<String>,
3507    /// Override the global per-(cluster, source-IP) connection limit for
3508    /// this cluster. `None` (field absent) inherits the global default
3509    /// `max_connections_per_ip`. `Some(0)` is explicit "unlimited for
3510    /// this cluster". `Some(n > 0)` overrides with the cluster-specific
3511    /// limit. The source IP is taken from the parsed proxy-protocol
3512    /// header when present, else `peer_addr`. On a UDP cluster it limits
3513    /// concurrent flows per datagram source address; UDP never inherits the
3514    /// global default.
3515    pub max_connections_per_ip: Option<u64>,
3516    /// Override the global per-(cluster, source-SUBNET) connection limit
3517    /// for this cluster. Same three-state semantics as
3518    /// [`FileClusterConfig::max_connections_per_ip`]: `None` inherits,
3519    /// `Some(0)` is explicit unlimited, `Some(n > 0)` overrides. This is
3520    /// an independent counter — a connection must satisfy BOTH caps.
3521    /// The mask itself is global and boot-time only (`subnet_ipv4_prefix`
3522    /// / `subnet_ipv6_prefix`), not overridable per cluster.
3523    pub max_connections_per_subnet: Option<u64>,
3524    /// Override the global `Retry-After` header value (seconds) emitted
3525    /// on HTTP 429 responses for this cluster. `None` inherits the global
3526    /// default. `Some(0)` omits the header. TCP clusters carry this
3527    /// field for shape uniformity but never emit the header (no HTTP
3528    /// envelope).
3529    pub retry_after: Option<u32>,
3530    /// Optional HTTP health-check configuration. The probe wire format
3531    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
3532    /// (h2c) when true.
3533    #[serde(default)]
3534    pub health_check: Option<FileHealthCheckConfig>,
3535    /// Optional UDP-specific cluster configuration, parsed from a
3536    /// `[clusters.<id>.udp]` block. Additive: clusters without a `udp`
3537    /// block produce `udp: None` on the resulting [`Cluster`].
3538    #[serde(default)]
3539    pub udp: Option<FileUdpClusterConfig>,
3540    /// Key an HTTP/HTTPS cluster's `HRW`/`MAGLEV` selection on the value of
3541    /// this request header instead of the client's source IP. A request
3542    /// without the header falls back to its source IP. Mutually exclusive
3543    /// with `affinity_cookie`; refused on a TCP cluster, which has no request
3544    /// headers and always keys on the source IP.
3545    #[serde(default)]
3546    pub affinity_header: Option<String>,
3547    /// Same as `affinity_header`, on the value of this request cookie.
3548    #[serde(default)]
3549    pub affinity_cookie: Option<String>,
3550    /// Shuffle sharding: restrict each client to a shard of
3551    /// `max(2, ceil(shard_percent × N / 100))` of the cluster's N primary
3552    /// backends, the top of the rendezvous ranking of its affinity key.
3553    /// Off when unset. See [`validate_shuffle_sharding`].
3554    #[serde(default)]
3555    pub shard_percent: Option<u32>,
3556    /// Sharding applies only from this many primary backends; defaults to
3557    /// [`DEFAULT_SHARD_MIN_BACKENDS`].
3558    #[serde(default)]
3559    pub shard_min_backends: Option<u32>,
3560    /// `FALLBACK` (default) spills an exhausted shard over to the rest of the
3561    /// cluster; `STRICT` refuses.
3562    #[serde(default)]
3563    pub shard_mode: Option<ShardMode>,
3564    /// Override the global `max_connection_attempts` for this cluster: how
3565    /// many backend connections a request may try, the first included,
3566    /// before it is answered `503` (HTTP) or closed (TCP). `None` inherits
3567    /// the global value. See [`validate_max_connection_attempts`].
3568    #[serde(default)]
3569    pub max_connection_attempts: Option<u32>,
3570}
3571
3572/// UDP backend health-check configuration, parsed from
3573/// `[clusters.<id>.udp.health]`.
3574#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3575#[serde(deny_unknown_fields)]
3576pub struct FileUdpHealthConfig {
3577    /// probe mode, parsed in SCREAMING_SNAKE_CASE: `"HEALTH_OFF"`,
3578    /// `"TCP_PROBE"`, or `"UDP_PROBE"`. Defaults to `TCP_PROBE` when a
3579    /// `udp.health` block is present.
3580    pub mode: Option<UdpHealthMode>,
3581    pub tcp_port: Option<u32>,
3582    pub rise: Option<u32>,
3583    pub fall: Option<u32>,
3584    pub fail_open: Option<bool>,
3585    /// hex-free literal payload sent for a UDP probe.
3586    pub udp_probe_payload: Option<String>,
3587    pub probe_interval_seconds: Option<u32>,
3588    pub probe_timeout_seconds: Option<u32>,
3589}
3590
3591impl FileUdpHealthConfig {
3592    pub fn to_proto(&self) -> UdpHealthConfig {
3593        UdpHealthConfig {
3594            mode: self.mode.map(|m| m as i32),
3595            tcp_port: self.tcp_port,
3596            rise: self.rise,
3597            fall: self.fall,
3598            fail_open: self.fail_open,
3599            udp_probe_payload: self
3600                .udp_probe_payload
3601                .as_ref()
3602                .map(|p| p.as_bytes().to_owned()),
3603            probe_interval_seconds: self.probe_interval_seconds,
3604            probe_timeout_seconds: self.probe_timeout_seconds,
3605        }
3606    }
3607}
3608
3609/// UDP-specific cluster knobs, parsed from a `[clusters.<id>.udp]` block.
3610#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3611#[serde(deny_unknown_fields)]
3612pub struct FileUdpClusterConfig {
3613    /// flow affinity key, parsed in SCREAMING_SNAKE_CASE: `"SOURCE_IP"`
3614    /// (default) or `"SOURCE_IP_PORT"`.
3615    pub affinity_key: Option<UdpAffinityKey>,
3616    /// expected replies per flow; 0 = unlimited.
3617    pub responses: Option<u32>,
3618    /// max client datagrams per flow; 0 = unlimited.
3619    pub requests: Option<u32>,
3620    /// send a PROXY protocol v2 header to the backend.
3621    pub send_proxy_protocol: Option<bool>,
3622    /// prepend PPv2 to every datagram; false = first-datagram only.
3623    pub proxy_protocol_every_datagram: Option<bool>,
3624    /// optional backend health-check configuration.
3625    pub health: Option<FileUdpHealthConfig>,
3626}
3627
3628impl FileUdpClusterConfig {
3629    pub fn to_proto(&self) -> UdpClusterConfig {
3630        UdpClusterConfig {
3631            affinity_key: self.affinity_key.map(|k| k as i32),
3632            responses: self.responses,
3633            requests: self.requests,
3634            send_proxy_protocol: self.send_proxy_protocol,
3635            proxy_protocol_every_datagram: self.proxy_protocol_every_datagram,
3636            health: self.health.as_ref().map(|h| h.to_proto()),
3637        }
3638    }
3639}
3640
3641#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3642#[serde(deny_unknown_fields)]
3643pub struct BackendConfig {
3644    pub address: SocketAddr,
3645    pub weight: Option<u8>,
3646    pub sticky_id: Option<String>,
3647    pub backup: Option<bool>,
3648    pub backend_id: Option<String>,
3649    /// Dial this UNIX domain socket (absolute path, or `@name` for the Linux
3650    /// abstract namespace) instead of `address`, which then only identifies
3651    /// the backend within its cluster and must be unique there.
3652    pub unix_path: Option<String>,
3653}
3654
3655/// Check the `unix_path` of a cluster's backends: each parses, and each unix
3656/// backend's identity `address` is used by no other backend of the cluster,
3657/// since sozu tells backends apart by address.
3658fn validate_backend_unix_paths(
3659    cluster_id: &str,
3660    backends: &[BackendConfig],
3661) -> Result<(), ConfigError> {
3662    for (index, backend) in backends.iter().enumerate() {
3663        let Some(unix_path) = backend.unix_path.as_deref() else {
3664            continue;
3665        };
3666        let invalid = |reason| ConfigError::InvalidBackendUnixPath {
3667            cluster_id: cluster_id.to_owned(),
3668            address: backend.address,
3669            reason,
3670        };
3671        parse_backend_unix_path(unix_path).map_err(invalid)?;
3672        if backends
3673            .iter()
3674            .enumerate()
3675            .any(|(other, b)| other != index && b.address == backend.address)
3676        {
3677            return Err(invalid(
3678                "a unix backend's address is its identity and must be unique in the cluster",
3679            ));
3680        }
3681    }
3682    Ok(())
3683}
3684
3685/// Turns a `host:port` string into socket addresses.
3686pub type BackendResolver<'a> = dyn Fn(&str) -> std::io::Result<Vec<SocketAddr>> + 'a;
3687
3688/// The system resolver (`std::net::ToSocketAddrs`).
3689pub fn system_resolver(address: &str) -> std::io::Result<Vec<SocketAddr>> {
3690    address.to_socket_addrs().map(Iterator::collect)
3691}
3692
3693/// A backend as written in the configuration file. `address` is either a
3694/// socket address (`"127.0.0.1:8080"`, `"[::1]:8080"`) or a `host:port`
3695/// (`"ingress:8080"`) that [`FileBackendConfig::resolve`] turns into one
3696/// [`BackendConfig`] per resolved address when the file is loaded
3697/// (sozu-proxy/sozu#347). Workers only ever receive socket addresses.
3698#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3699#[serde(deny_unknown_fields)]
3700pub struct FileBackendConfig {
3701    pub address: String,
3702    pub weight: Option<u8>,
3703    pub sticky_id: Option<String>,
3704    pub backup: Option<bool>,
3705    pub backend_id: Option<String>,
3706    /// Carried unchanged onto every backend this entry resolves to; see
3707    /// [`BackendConfig::unix_path`].
3708    pub unix_path: Option<String>,
3709}
3710
3711/// Whether `host` is spelled like a legacy `inet_aton` address: every
3712/// dot-separated component is non-empty and either all decimal/octal digits
3713/// or `0x`/`0X` followed by ASCII hex digits. Ordinary names (`dead.beef`,
3714/// `123.example.com`) have a non-numeric component and do not match.
3715fn is_legacy_numeric_host(host: &str) -> bool {
3716    host.split('.').all(|component| {
3717        !component.is_empty()
3718            && match component
3719                .strip_prefix("0x")
3720                .or_else(|| component.strip_prefix("0X"))
3721            {
3722                Some(hex) => hex.bytes().all(|byte| byte.is_ascii_hexdigit()),
3723                None => component.bytes().all(|byte| byte.is_ascii_digit()),
3724            }
3725    })
3726}
3727
3728impl FileBackendConfig {
3729    /// Resolves this entry into backends, once, through `resolver`
3730    /// ([`system_resolver`] outside tests).
3731    ///
3732    /// A socket-address literal never reaches the resolver and keeps its
3733    /// configured `backend_id`, or the positional default id
3734    /// `<cluster_id>-<index>-<address>`, where `index` is the entry's position
3735    /// in the file's `backends` list, so it does not move when a name before
3736    /// it resolves to more or fewer addresses. A `host:port`
3737    /// yields one backend per distinct resolved address, sorted so the
3738    /// result does not depend on the resolver's answer order, each with a
3739    /// deterministic id: `<backend_id>-<address>` when `backend_id` is set,
3740    /// `<cluster_id>-<host:port>-<address>` otherwise. When the name
3741    /// resolves, a configured `sticky_id` is suffixed with `-<address>` too,
3742    /// so it stays unique in the cluster and does not change when the answer
3743    /// grows or shrinks.
3744    ///
3745    /// A host made only of dot-separated decimal, octal or `0x` hexadecimal
3746    /// components that is not a valid IP literal (`010.0.0.1`, `1.2.3`,
3747    /// `0x7f.1`) is refused before the resolver: getaddrinfo reads such
3748    /// strings as legacy `inet_aton` shorthand (`8.0.0.1`, `1.2.0.3`,
3749    /// `127.0.0.1`), which the socket-address parser always rejected. An
3750    /// unbracketed IPv6 literal (`::1:8080`) is refused the same way:
3751    /// getaddrinfo would split it on the last colon.
3752    pub fn resolve(
3753        &self,
3754        cluster_id: &str,
3755        index: usize,
3756        resolver: &BackendResolver<'_>,
3757    ) -> Result<Vec<BackendConfig>, ConfigError> {
3758        if let Ok(address) = self.address.parse::<SocketAddr>() {
3759            return Ok(vec![BackendConfig {
3760                address,
3761                weight: self.weight,
3762                sticky_id: self.sticky_id.clone(),
3763                backup: self.backup,
3764                backend_id: Some(
3765                    self.backend_id
3766                        .to_owned()
3767                        .unwrap_or_else(|| format!("{cluster_id}-{index}-{address}")),
3768                ),
3769                unix_path: self.unix_path.clone(),
3770            }]);
3771        }
3772
3773        let error = |reason: String| ConfigError::BackendAddressResolution {
3774            cluster_id: cluster_id.to_owned(),
3775            address: self.address.clone(),
3776            reason,
3777        };
3778        if let Some((host, _port)) = self.address.rsplit_once(':') {
3779            if host.contains(':') {
3780                return Err(error(format!(
3781                    "{host:?} is an unbracketed IPv6 literal, IPv6 literals must use [address]:port"
3782                )));
3783            }
3784            if !host.is_empty() && is_legacy_numeric_host(host) {
3785                return Err(error(format!(
3786                    "{host:?} is not a valid IP address literal and numeric shorthand is not accepted"
3787                )));
3788            }
3789        }
3790        let addresses = resolver(&self.address).map_err(|io_error| error(io_error.to_string()))?;
3791        self.expand(cluster_id, addresses)
3792    }
3793
3794    /// Turns the addresses a `host:port` entry resolved to into backends:
3795    /// the naming half of [`FileBackendConfig::resolve`], kept apart from
3796    /// the resolver so it can be exercised with any set of addresses.
3797    fn expand(
3798        &self,
3799        cluster_id: &str,
3800        mut addresses: Vec<SocketAddr>,
3801    ) -> Result<Vec<BackendConfig>, ConfigError> {
3802        addresses.sort_unstable();
3803        addresses.dedup();
3804        if addresses.is_empty() {
3805            return Err(ConfigError::BackendAddressResolution {
3806                cluster_id: cluster_id.to_owned(),
3807                address: self.address.clone(),
3808                reason: "the name resolved to no address".to_owned(),
3809            });
3810        }
3811
3812        let backends: Vec<BackendConfig> = addresses
3813            .iter()
3814            .map(|address| BackendConfig {
3815                address: *address,
3816                weight: self.weight,
3817                sticky_id: self
3818                    .sticky_id
3819                    .as_ref()
3820                    .map(|sticky_id| format!("{sticky_id}-{address}")),
3821                backup: self.backup,
3822                backend_id: Some(match &self.backend_id {
3823                    Some(backend_id) => format!("{backend_id}-{address}"),
3824                    None => format!("{cluster_id}-{}-{address}", self.address),
3825                }),
3826                unix_path: self.unix_path.clone(),
3827            })
3828            .collect();
3829
3830        // POST: one backend per distinct resolved address, every one carrying
3831        // a distinct id. Downstream state keys backends by `(backend_id,
3832        // address)`, so the address alone already keeps them apart; the
3833        // address-suffixed id keeps logs, metrics and removals unambiguous.
3834        debug_assert_eq!(backends.len(), addresses.len());
3835        debug_assert!(
3836            backends
3837                .iter()
3838                .map(|backend| backend.backend_id.as_deref())
3839                .collect::<HashSet<_>>()
3840                .len()
3841                == backends.len(),
3842            "resolved backends must carry distinct ids"
3843        );
3844        Ok(backends)
3845    }
3846}
3847
3848impl FileClusterConfig {
3849    pub fn to_cluster_config(
3850        self,
3851        cluster_id: &str,
3852        expect_proxy: &HashSet<SocketAddr>,
3853    ) -> Result<ClusterConfig, ConfigError> {
3854        self.to_cluster_config_with(cluster_id, expect_proxy, Some(&system_resolver))
3855    }
3856
3857    /// [`FileClusterConfig::to_cluster_config`] with the backend resolver
3858    /// chosen by the caller. `None` leaves every `host:port` entry out and
3859    /// keeps only socket-address literals: a control command reads the
3860    /// configuration for its scalar settings and must not depend on DNS.
3861    pub fn to_cluster_config_with(
3862        self,
3863        cluster_id: &str,
3864        expect_proxy: &HashSet<SocketAddr>,
3865        resolver: Option<&BackendResolver<'_>>,
3866    ) -> Result<ClusterConfig, ConfigError> {
3867        // PRE: every frontend that converts cleanly must survive into the built
3868        // cluster — no frontend is silently dropped during conversion.
3869        let requested_frontend_count = self.frontends.len();
3870        validate_affinity_key(
3871            self.affinity_header.as_deref(),
3872            self.affinity_cookie.as_deref(),
3873        )
3874        .map_err(|reason| ConfigError::InvalidAffinityKey {
3875            cluster_id: cluster_id.to_owned(),
3876            reason,
3877        })?;
3878        validate_shuffle_sharding(
3879            self.shard_percent,
3880            self.shard_min_backends,
3881            self.shard_mode.map(|mode| mode as i32),
3882        )
3883        .map_err(|reason| ConfigError::InvalidShuffleSharding {
3884            cluster_id: cluster_id.to_owned(),
3885            reason,
3886        })?;
3887        if let Some(attempts) = self.max_connection_attempts {
3888            validate_max_connection_attempts(attempts).map_err(|reason| {
3889                ConfigError::InvalidMaxConnectionAttempts {
3890                    cluster_id: Some(cluster_id.to_owned()),
3891                    value: attempts,
3892                    reason,
3893                }
3894            })?;
3895        }
3896        let mut backends = Vec::with_capacity(self.backends.len());
3897        for (index, backend) in self.backends.iter().enumerate() {
3898            match resolver {
3899                Some(resolver) => backends.extend(backend.resolve(cluster_id, index, resolver)?),
3900                None if backend.address.parse::<SocketAddr>().is_ok() => {
3901                    backends.extend(backend.resolve(cluster_id, index, &system_resolver)?)
3902                }
3903                None => {}
3904            }
3905        }
3906        // POST: every file entry yields at least one backend when resolving.
3907        debug_assert!(resolver.is_none() || backends.len() >= self.backends.len());
3908        let (health_check, health_check_header_files) = match self
3909            .health_check
3910            .as_ref()
3911            .map(FileHealthCheckConfig::to_parts)
3912            .transpose()
3913            .map_err(|reason| ConfigError::InvalidHealthCheck {
3914                cluster_id: cluster_id.to_owned(),
3915                reason,
3916            })? {
3917            Some((config, files)) => (Some(config), files),
3918            None => (None, Vec::new()),
3919        };
3920        validate_backend_unix_paths(cluster_id, &backends)?;
3921        // The file entries, not only the expanded backends: an entry DNS did
3922        // not expand (no resolver) still names a `unix_path`.
3923        if health_check.as_ref().is_some_and(|hc| hc.port.is_some())
3924            && self.backends.iter().any(|b| b.unix_path.is_some())
3925        {
3926            return Err(ConfigError::InvalidHealthCheck {
3927                cluster_id: cluster_id.to_owned(),
3928                reason: HEALTH_CHECK_PORT_UNIX_REFUSED,
3929            });
3930        }
3931        match self.protocol {
3932            FileClusterProtocolConfig::Tcp => {
3933                // A TCP cluster has no request to read a header or a cookie
3934                // from: it always keys on the source IP. Refused rather than
3935                // ignored, so the file never claims a key the proxy does not use.
3936                if self.affinity_header.is_some() || self.affinity_cookie.is_some() {
3937                    return Err(ConfigError::InvalidAffinityKey {
3938                        cluster_id: cluster_id.to_owned(),
3939                        reason: "a TCP cluster always keys on the client source IP: \
3940                                 affinity_header and affinity_cookie apply to HTTP clusters only",
3941                    });
3942                }
3943                let mut has_expect_proxy = None;
3944                let mut frontends = Vec::new();
3945                for f in self.frontends {
3946                    if expect_proxy.contains(&f.address) {
3947                        match has_expect_proxy {
3948                            Some(true) => {}
3949                            Some(false) => {
3950                                return Err(ConfigError::Incompatible {
3951                                    object: ObjectKind::Cluster,
3952                                    id: cluster_id.to_owned(),
3953                                    kind: IncompatibilityKind::ProxyProtocol,
3954                                });
3955                            }
3956                            None => has_expect_proxy = Some(true),
3957                        }
3958                    } else {
3959                        match has_expect_proxy {
3960                            Some(false) => {}
3961                            Some(true) => {
3962                                return Err(ConfigError::Incompatible {
3963                                    object: ObjectKind::Cluster,
3964                                    id: cluster_id.to_owned(),
3965                                    kind: IncompatibilityKind::ProxyProtocol,
3966                                });
3967                            }
3968                            None => has_expect_proxy = Some(false),
3969                        }
3970                    }
3971                    let tcp_frontend = f.to_tcp_front()?;
3972                    frontends.push(tcp_frontend);
3973                }
3974
3975                let send_proxy = self.send_proxy.unwrap_or(false);
3976                let expect_proxy = has_expect_proxy.unwrap_or(false);
3977                let proxy_protocol = match (send_proxy, expect_proxy) {
3978                    (true, true) => Some(ProxyProtocolConfig::RelayHeader),
3979                    (true, false) => Some(ProxyProtocolConfig::SendHeader),
3980                    (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
3981                    _ => None,
3982                };
3983
3984                let answers = match self.answers.as_ref() {
3985                    Some(map) => load_answers(map)?,
3986                    None => BTreeMap::new(),
3987                };
3988
3989                let udp = self.udp.as_ref().map(|u| u.to_proto());
3990                // POST: every requested frontend converted (none dropped), and
3991                // the resolved proxy-protocol mode is the documented function of
3992                // the (send, expect) pair — expect-only must never resolve to a
3993                // send-header mode and vice versa, which would corrupt the wire
3994                // framing.
3995                debug_assert_eq!(
3996                    frontends.len(),
3997                    requested_frontend_count,
3998                    "every TCP frontend must survive conversion"
3999                );
4000                debug_assert_eq!(
4001                    proxy_protocol,
4002                    match (send_proxy, expect_proxy) {
4003                        (true, true) => Some(ProxyProtocolConfig::RelayHeader),
4004                        (true, false) => Some(ProxyProtocolConfig::SendHeader),
4005                        (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
4006                        (false, false) => None,
4007                    },
4008                    "proxy_protocol must be the (send, expect) function"
4009                );
4010
4011                Ok(ClusterConfig::Tcp(TcpClusterConfig {
4012                    cluster_id: cluster_id.to_string(),
4013                    frontends,
4014                    backends,
4015                    proxy_protocol,
4016                    load_balancing: self.load_balancing,
4017                    load_metric: self.load_metric,
4018                    answers,
4019                    https_redirect_port: self.https_redirect_port,
4020                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
4021                    www_authenticate: self.www_authenticate,
4022                    max_connections_per_ip: self.max_connections_per_ip,
4023                    max_connections_per_subnet: self.max_connections_per_subnet,
4024                    retry_after: self.retry_after,
4025                    health_check,
4026                    health_check_header_files,
4027                    udp,
4028                    shard_percent: self.shard_percent,
4029                    shard_min_backends: self.shard_min_backends,
4030                    shard_mode: self.shard_mode,
4031                    max_connection_attempts: self.max_connection_attempts,
4032                }))
4033            }
4034            FileClusterProtocolConfig::Http => {
4035                let mut frontends = Vec::new();
4036                for frontend in self.frontends {
4037                    let http_frontend = frontend.to_http_front(cluster_id)?;
4038                    frontends.push(http_frontend);
4039                }
4040
4041                let answer_503 = self.answer_503.as_ref().and_then(|path| {
4042                    Config::load_file(path)
4043                        .map_err(|e| {
4044                            error!("cannot load 503 error page at path '{}': {:?}", path, e);
4045                            e
4046                        })
4047                        .ok()
4048                });
4049
4050                let answers = match self.answers.as_ref() {
4051                    Some(map) => load_answers(map)?,
4052                    None => BTreeMap::new(),
4053                };
4054
4055                let udp = self.udp.as_ref().map(|u| u.to_proto());
4056                // POST: every requested HTTP frontend converted — none dropped
4057                // (a dropped frontend would silently stop routing a hostname).
4058                debug_assert_eq!(
4059                    frontends.len(),
4060                    requested_frontend_count,
4061                    "every HTTP frontend must survive conversion"
4062                );
4063
4064                Ok(ClusterConfig::Http(HttpClusterConfig {
4065                    cluster_id: cluster_id.to_string(),
4066                    frontends,
4067                    backends,
4068                    sticky_session: self.sticky_session.unwrap_or(false),
4069                    https_redirect: self.https_redirect.unwrap_or(false),
4070                    load_balancing: self.load_balancing,
4071                    load_metric: self.load_metric,
4072                    answer_503,
4073                    http2: self.http2,
4074                    answers,
4075                    https_redirect_port: self.https_redirect_port,
4076                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
4077                    www_authenticate: self.www_authenticate,
4078                    max_connections_per_ip: self.max_connections_per_ip,
4079                    max_connections_per_subnet: self.max_connections_per_subnet,
4080                    retry_after: self.retry_after,
4081                    health_check,
4082                    health_check_header_files,
4083                    udp,
4084                    affinity_header: self.affinity_header,
4085                    affinity_cookie: self.affinity_cookie,
4086                    shard_percent: self.shard_percent,
4087                    shard_min_backends: self.shard_min_backends,
4088                    shard_mode: self.shard_mode,
4089                    max_connection_attempts: self.max_connection_attempts,
4090                }))
4091            }
4092        }
4093    }
4094}
4095
4096#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4097#[serde(deny_unknown_fields)]
4098pub struct HttpFrontendConfig {
4099    pub address: SocketAddr,
4100    pub hostname: String,
4101    pub path: PathRule,
4102    pub method: Option<String>,
4103    pub certificate: Option<String>,
4104    pub key: Option<String>,
4105    pub certificate_chain: Option<Vec<String>>,
4106    #[serde(default)]
4107    pub tls_versions: Vec<TlsVersion>,
4108    #[serde(default)]
4109    pub position: RulePosition,
4110    pub tags: Option<BTreeMap<String, String>>,
4111    /// Resolved redirect policy. `None` keeps the proto-default `FORWARD`.
4112    #[serde(default)]
4113    pub redirect: Option<RedirectPolicy>,
4114    /// Resolved redirect scheme. `None` keeps the proto-default `USE_SAME`.
4115    #[serde(default)]
4116    pub redirect_scheme: Option<RedirectScheme>,
4117    #[serde(default)]
4118    pub redirect_template: Option<String>,
4119    #[serde(default)]
4120    pub rewrite_host: Option<String>,
4121    #[serde(default)]
4122    pub rewrite_path: Option<String>,
4123    #[serde(default)]
4124    pub rewrite_port: Option<u32>,
4125    #[serde(default)]
4126    pub required_auth: Option<bool>,
4127    /// Header mutations applied to requests and/or responses passing through
4128    /// this frontend. Empty by default.
4129    #[serde(default)]
4130    pub headers: Vec<Header>,
4131    /// Resolved per-frontend HSTS (RFC 6797) policy. `None` means inherit
4132    /// the listener default at frontend-add time in the worker.
4133    #[serde(default)]
4134    pub hsts: Option<HstsConfig>,
4135    /// Header predicates the request must all satisfy for this frontend to
4136    /// match (sozu-proxy/sozu#1253). Empty by default.
4137    #[serde(default)]
4138    pub header_predicates: Vec<HeaderPredicate>,
4139}
4140
4141impl fmt::Debug for HttpFrontendConfig {
4142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4143        let certificate = self.certificate.as_ref().map(|_| "[redacted]");
4144        let certificate_len = self.certificate.as_ref().map(String::len);
4145        let key = self.key.as_ref().map(|_| "[redacted]");
4146        let key_len = self.key.as_ref().map(String::len);
4147        let certificate_chain = self.certificate_chain.as_ref().map(|_| "[redacted]");
4148        let certificate_chain_count = self.certificate_chain.as_ref().map(Vec::len);
4149        let certificate_chain_len = self.certificate_chain.as_ref().map(|chain| {
4150            chain
4151                .iter()
4152                .map(String::len)
4153                .fold(0usize, usize::saturating_add)
4154        });
4155        let method_len = self.method.as_ref().map(String::len);
4156        let redirect_template_len = self.redirect_template.as_ref().map(String::len);
4157        let rewrite_host_len = self.rewrite_host.as_ref().map(String::len);
4158        let rewrite_path_len = self.rewrite_path.as_ref().map(String::len);
4159
4160        f.debug_struct("HttpFrontendConfig")
4161            .field("address", &self.address)
4162            .field("hostname_len", &self.hostname.len())
4163            .field("path_kind", &self.path.kind)
4164            .field("path_len", &self.path.value.len())
4165            .field("method_len", &method_len)
4166            .field("certificate", &certificate)
4167            .field("certificate_len", &certificate_len)
4168            .field("key", &key)
4169            .field("key_len", &key_len)
4170            .field("certificate_chain", &certificate_chain)
4171            .field("certificate_chain_count", &certificate_chain_count)
4172            .field("certificate_chain_len", &certificate_chain_len)
4173            .field("tls_versions_count", &self.tls_versions.len())
4174            .field("position", &self.position)
4175            .field(
4176                "tags_count",
4177                &self.tags.as_ref().map(BTreeMap::len).unwrap_or_default(),
4178            )
4179            .field("redirect", &self.redirect)
4180            .field("redirect_scheme", &self.redirect_scheme)
4181            .field("redirect_template_len", &redirect_template_len)
4182            .field("rewrite_host_len", &rewrite_host_len)
4183            .field("rewrite_path_len", &rewrite_path_len)
4184            .field("rewrite_port", &self.rewrite_port)
4185            .field("required_auth", &self.required_auth)
4186            .field("headers_count", &self.headers.len())
4187            .field("hsts", &self.hsts)
4188            .field("header_predicates_count", &self.header_predicates.len())
4189            .finish()
4190    }
4191}
4192
4193impl HttpFrontendConfig {
4194    pub fn generate_requests(&self, cluster_id: &str) -> Vec<Request> {
4195        let mut v = Vec::new();
4196
4197        let tags = self.tags.clone().unwrap_or_default();
4198
4199        if self.key.is_some() && self.certificate.is_some() {
4200            v.push(
4201                RequestType::AddCertificate(AddCertificate {
4202                    address: self.address.into(),
4203                    certificate: CertificateAndKey {
4204                        key: self.key.clone().unwrap(),
4205                        certificate: self.certificate.clone().unwrap(),
4206                        certificate_chain: self.certificate_chain.clone().unwrap_or_default(),
4207                        versions: self.tls_versions.iter().map(|v| *v as i32).collect(),
4208                        // This field is used to override the certificate subject and san, we should not set it when
4209                        // loading the configuration, as we may provide a wildcard certificate for a specific domain.
4210                        // As a result, we will reject legit traffic for others domains as the certificate resolver will
4211                        // not load twice the same certificate and then do not register the certificate for others domains.
4212                        names: vec![],
4213                    },
4214                    expired_at: None,
4215                })
4216                .into(),
4217            );
4218
4219            v.push(
4220                RequestType::add_https_frontend(RequestHttpFrontend {
4221                    cluster_id: Some(cluster_id.to_string()),
4222                    address: self.address.into(),
4223                    hostname: self.hostname.clone(),
4224                    path: self.path.clone(),
4225                    method: self.method.clone(),
4226                    position: self.position.into(),
4227                    tags,
4228                    redirect: self.redirect.map(|r| r as i32),
4229                    required_auth: self.required_auth,
4230                    redirect_scheme: self.redirect_scheme.map(|s| s as i32),
4231                    redirect_template: self.redirect_template.clone(),
4232                    rewrite_host: self.rewrite_host.clone(),
4233                    rewrite_path: self.rewrite_path.clone(),
4234                    rewrite_port: self.rewrite_port,
4235                    headers: self.headers.clone(),
4236                    hsts: self.hsts,
4237                    header_predicates: self.header_predicates.clone(),
4238                })
4239                .into(),
4240            );
4241        } else {
4242            //create the front both for HTTP and HTTPS if possible
4243            v.push(
4244                RequestType::add_http_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        }
4266
4267        v
4268    }
4269}
4270
4271#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4272#[serde(deny_unknown_fields)]
4273pub struct HttpClusterConfig {
4274    pub cluster_id: String,
4275    pub frontends: Vec<HttpFrontendConfig>,
4276    pub backends: Vec<BackendConfig>,
4277    pub sticky_session: bool,
4278    pub https_redirect: bool,
4279    pub load_balancing: LoadBalancingAlgorithms,
4280    pub load_metric: Option<LoadMetric>,
4281    pub answer_503: Option<String>,
4282    pub http2: Option<bool>,
4283    /// Per-status template body map (already loaded from disk). Maps to
4284    /// the proto [`Cluster::answers`] field.
4285    #[serde(default)]
4286    pub answers: BTreeMap<String, String>,
4287    #[serde(default)]
4288    pub https_redirect_port: Option<u32>,
4289    #[serde(default)]
4290    pub authorized_hashes: Vec<String>,
4291    #[serde(default)]
4292    pub www_authenticate: Option<String>,
4293    /// Per-cluster override of the global `max_connections_per_ip`. See
4294    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
4295    #[serde(default)]
4296    pub max_connections_per_ip: Option<u64>,
4297    /// Per-cluster override of the global `max_connections_per_subnet`.
4298    /// See [`FileClusterConfig::max_connections_per_subnet`] for
4299    /// semantics: an independent second cap, not a replacement for the
4300    /// per-IP one.
4301    #[serde(default)]
4302    pub max_connections_per_subnet: Option<u64>,
4303    /// Per-cluster override of the global `retry_after` HTTP-429 header
4304    /// value (seconds). See [`FileClusterConfig::retry_after`].
4305    #[serde(default)]
4306    pub retry_after: Option<u32>,
4307    /// Optional HTTP health-check configuration. The probe wire format
4308    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
4309    /// (h2c) when true.
4310    #[serde(default)]
4311    pub health_check: Option<HealthCheckConfig>,
4312    /// Where each header named by `health_check` reads its value from, in the
4313    /// same order. References, never values: they are kept for the protected
4314    /// activation and never copied into an `AddCluster` order.
4315    #[serde(default)]
4316    pub health_check_header_files: Vec<FileHealthCheckHeader>,
4317    /// Optional UDP-specific cluster configuration. Always `None` for HTTP
4318    /// clusters; carried for shape uniformity with the proto [`Cluster`].
4319    #[serde(default)]
4320    pub udp: Option<UdpClusterConfig>,
4321    /// Request header keying `HRW`/`MAGLEV` selection. See
4322    /// [`FileClusterConfig::affinity_header`].
4323    #[serde(default)]
4324    pub affinity_header: Option<String>,
4325    /// Request cookie keying `HRW`/`MAGLEV` selection. See
4326    /// [`FileClusterConfig::affinity_cookie`].
4327    #[serde(default)]
4328    pub affinity_cookie: Option<String>,
4329    /// Shuffle-sharding share of the backends. See
4330    /// [`FileClusterConfig::shard_percent`].
4331    #[serde(default)]
4332    pub shard_percent: Option<u32>,
4333    /// See [`FileClusterConfig::shard_min_backends`].
4334    #[serde(default)]
4335    pub shard_min_backends: Option<u32>,
4336    /// See [`FileClusterConfig::shard_mode`].
4337    #[serde(default)]
4338    pub shard_mode: Option<ShardMode>,
4339    /// See [`FileClusterConfig::max_connection_attempts`].
4340    #[serde(default)]
4341    pub max_connection_attempts: Option<u32>,
4342}
4343
4344impl HttpClusterConfig {
4345    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
4346        // A header name without its value must not reach a worker, and the
4347        // value-file references do not either: until the protected activation
4348        // exists, refuse before any order is built.
4349        if !self.health_check_header_files.is_empty() {
4350            return Err(ConfigError::InvalidHealthCheck {
4351                cluster_id: self.cluster_id.clone(),
4352                reason: HEALTH_CHECK_VALUE_FILE_UNSUPPORTED,
4353            });
4354        }
4355        let mut v: Vec<Request> = vec![
4356            RequestType::AddCluster(Cluster {
4357                cluster_id: self.cluster_id.clone(),
4358                sticky_session: self.sticky_session,
4359                https_redirect: self.https_redirect,
4360                proxy_protocol: None,
4361                load_balancing: self.load_balancing as i32,
4362                answer_503: self.answer_503.clone(),
4363                load_metric: self.load_metric.map(|s| s as i32),
4364                http2: self.http2,
4365                answers: self.answers.clone(),
4366                https_redirect_port: self.https_redirect_port,
4367                authorized_hashes: self.authorized_hashes.clone(),
4368                www_authenticate: self.www_authenticate.clone(),
4369                max_connections_per_ip: self.max_connections_per_ip,
4370                max_connections_per_subnet: self.max_connections_per_subnet,
4371                retry_after: self.retry_after,
4372                health_check: self.health_check.clone(),
4373                udp: self.udp.clone(),
4374                affinity_header: self.affinity_header.clone(),
4375                affinity_cookie: self.affinity_cookie.clone(),
4376                shard_percent: self.shard_percent,
4377                shard_min_backends: self.shard_min_backends,
4378                shard_mode: self.shard_mode.map(|mode| mode as i32),
4379                max_connection_attempts: self.max_connection_attempts,
4380            })
4381            .into(),
4382        ];
4383
4384        for frontend in &self.frontends {
4385            let mut orders = frontend.generate_requests(&self.cluster_id);
4386            v.append(&mut orders);
4387        }
4388
4389        for (backend_count, backend) in self.backends.iter().enumerate() {
4390            let load_balancing_parameters = Some(LoadBalancingParams {
4391                weight: backend.weight.unwrap_or(100) as i32,
4392            });
4393
4394            v.push(
4395                RequestType::add_backend(AddBackend {
4396                    cluster_id: self.cluster_id.clone(),
4397                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
4398                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
4399                    }),
4400                    address: backend.address.into(),
4401                    load_balancing_parameters,
4402                    sticky_id: backend.sticky_id.clone(),
4403                    backup: backend.backup,
4404                    unix_path: backend.unix_path.clone(),
4405                })
4406                .into(),
4407            );
4408        }
4409
4410        // POST: the order stream begins with exactly one AddCluster and emits
4411        // exactly one AddBackend per configured backend — a missing AddCluster
4412        // would orphan every backend, and a miscounted backend set would
4413        // silently drop or duplicate a backend registration.
4414        debug_assert!(
4415            matches!(
4416                v.first().and_then(|r| r.request_type.as_ref()),
4417                Some(RequestType::AddCluster(_))
4418            ),
4419            "HTTP cluster orders must lead with an AddCluster"
4420        );
4421        debug_assert_eq!(
4422            v.iter()
4423                .filter(|r| matches!(
4424                    r.request_type,
4425                    Some(RequestType::AddBackend(_)) | Some(RequestType::AddUnixBackend(_))
4426                ))
4427                .count(),
4428            self.backends.len(),
4429            "one AddBackend order per configured backend"
4430        );
4431        Ok(v)
4432    }
4433}
4434
4435#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4436pub struct TcpFrontendConfig {
4437    pub address: SocketAddr,
4438    pub tags: Option<BTreeMap<String, String>>,
4439    /// `true` when this frontend's address resolves to a `protocol = "udp"`
4440    /// listener. Resolved at config-load in `ConfigBuilder::populate_clusters`
4441    /// from `known_addresses`; selects `AddUdpFrontend` over `AddTcpFrontend`
4442    /// in [`TcpClusterConfig::generate_requests`]. A UDP cluster is declared as
4443    /// a `protocol = "tcp"` cluster whose frontends point at UDP listeners and
4444    /// whose datagram knobs live under `[clusters.<id>.udp]`.
4445    #[serde(default)]
4446    pub udp: bool,
4447    /// SNI hostname this frontend matches, validated and normalized by
4448    /// [`FileClusterFrontendConfig::to_tcp_front`] (sozu-proxy/sozu#1279).
4449    /// `None` matches regardless of SNI (a raw-TCP fallback).
4450    #[serde(default)]
4451    pub sni: Option<String>,
4452    /// ALPN protocol names this frontend matches; empty is the catch-all
4453    /// for its `sni` on this listener.
4454    #[serde(default)]
4455    pub alpn: Vec<String>,
4456}
4457
4458#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4459pub struct TcpClusterConfig {
4460    pub cluster_id: String,
4461    pub frontends: Vec<TcpFrontendConfig>,
4462    pub backends: Vec<BackendConfig>,
4463    #[serde(default)]
4464    pub proxy_protocol: Option<ProxyProtocolConfig>,
4465    pub load_balancing: LoadBalancingAlgorithms,
4466    pub load_metric: Option<LoadMetric>,
4467    /// Per-status template body map (already loaded from disk). Even
4468    /// though TCP clusters do not emit HTTP responses, the field is
4469    /// carried for shape uniformity with [`HttpClusterConfig`].
4470    #[serde(default)]
4471    pub answers: BTreeMap<String, String>,
4472    #[serde(default)]
4473    pub https_redirect_port: Option<u32>,
4474    #[serde(default)]
4475    pub authorized_hashes: Vec<String>,
4476    #[serde(default)]
4477    pub www_authenticate: Option<String>,
4478    /// Per-cluster override of the global `max_connections_per_ip`. See
4479    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
4480    #[serde(default)]
4481    pub max_connections_per_ip: Option<u64>,
4482    /// Per-cluster override of the global `max_connections_per_subnet`.
4483    /// See [`FileClusterConfig::max_connections_per_subnet`] for
4484    /// semantics: an independent second cap, not a replacement for the
4485    /// per-IP one.
4486    #[serde(default)]
4487    pub max_connections_per_subnet: Option<u64>,
4488    /// Per-cluster override of the global `retry_after`. TCP listeners
4489    /// never emit `Retry-After`; the field is carried for shape
4490    /// uniformity with [`HttpClusterConfig`].
4491    #[serde(default)]
4492    pub retry_after: Option<u32>,
4493    /// Optional HTTP health-check configuration. TCP clusters carry this
4494    /// field for shape uniformity with [`HttpClusterConfig`]; probes are
4495    /// HTTP/1.1 only and TCP-only backends should leave this absent.
4496    #[serde(default)]
4497    pub health_check: Option<HealthCheckConfig>,
4498    /// Where each header named by `health_check` reads its value from, in the
4499    /// same order. References, never values: they are kept for the protected
4500    /// activation and never copied into an `AddCluster` order.
4501    #[serde(default)]
4502    pub health_check_header_files: Vec<FileHealthCheckHeader>,
4503    /// Optional UDP-specific cluster configuration, parsed from a
4504    /// `[clusters.<id>.udp]` block on this cluster.
4505    #[serde(default)]
4506    pub udp: Option<UdpClusterConfig>,
4507    /// Shuffle-sharding share of the backends. See
4508    /// [`FileClusterConfig::shard_percent`].
4509    #[serde(default)]
4510    pub shard_percent: Option<u32>,
4511    /// See [`FileClusterConfig::shard_min_backends`].
4512    #[serde(default)]
4513    pub shard_min_backends: Option<u32>,
4514    /// See [`FileClusterConfig::shard_mode`].
4515    #[serde(default)]
4516    pub shard_mode: Option<ShardMode>,
4517    /// See [`FileClusterConfig::max_connection_attempts`].
4518    #[serde(default)]
4519    pub max_connection_attempts: Option<u32>,
4520}
4521
4522impl TcpClusterConfig {
4523    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
4524        // A header name without its value must not reach a worker, and the
4525        // value-file references do not either: until the protected activation
4526        // exists, refuse before any order is built.
4527        if !self.health_check_header_files.is_empty() {
4528            return Err(ConfigError::InvalidHealthCheck {
4529                cluster_id: self.cluster_id.clone(),
4530                reason: HEALTH_CHECK_VALUE_FILE_UNSUPPORTED,
4531            });
4532        }
4533        let mut v: Vec<Request> = vec![
4534            RequestType::AddCluster(Cluster {
4535                cluster_id: self.cluster_id.clone(),
4536                sticky_session: false,
4537                https_redirect: false,
4538                proxy_protocol: self.proxy_protocol.map(|s| s as i32),
4539                load_balancing: self.load_balancing as i32,
4540                load_metric: self.load_metric.map(|s| s as i32),
4541                answer_503: None,
4542                http2: None,
4543                answers: self.answers.clone(),
4544                https_redirect_port: self.https_redirect_port,
4545                authorized_hashes: self.authorized_hashes.clone(),
4546                www_authenticate: self.www_authenticate.clone(),
4547                max_connections_per_ip: self.max_connections_per_ip,
4548                max_connections_per_subnet: self.max_connections_per_subnet,
4549                retry_after: self.retry_after,
4550                health_check: self.health_check.clone(),
4551                udp: self.udp.clone(),
4552                // A TCP cluster always keys on the source IP.
4553                affinity_header: None,
4554                affinity_cookie: None,
4555                shard_percent: self.shard_percent,
4556                shard_min_backends: self.shard_min_backends,
4557                shard_mode: self.shard_mode.map(|mode| mode as i32),
4558                max_connection_attempts: self.max_connection_attempts,
4559            })
4560            .into(),
4561        ];
4562
4563        for frontend in &self.frontends {
4564            // A frontend whose address resolves to a `protocol = "udp"`
4565            // listener (flagged in `populate_clusters`) is added as a UDP
4566            // frontend; all others stay TCP. Mixed TCP/UDP frontends on the
4567            // same cluster are supported.
4568            if frontend.udp {
4569                v.push(
4570                    RequestType::AddUdpFrontend(RequestUdpFrontend {
4571                        cluster_id: self.cluster_id.clone(),
4572                        address: frontend.address.into(),
4573                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
4574                    })
4575                    .into(),
4576                );
4577            } else {
4578                v.push(
4579                    RequestType::AddTcpFrontend(RequestTcpFrontend {
4580                        cluster_id: self.cluster_id.clone(),
4581                        address: frontend.address.into(),
4582                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
4583                        sni: frontend.sni.clone(),
4584                        alpn: frontend.alpn.clone(),
4585                    })
4586                    .into(),
4587                );
4588            }
4589        }
4590
4591        for (backend_count, backend) in self.backends.iter().enumerate() {
4592            let load_balancing_parameters = Some(LoadBalancingParams {
4593                weight: backend.weight.unwrap_or(100) as i32,
4594            });
4595
4596            v.push(
4597                RequestType::add_backend(AddBackend {
4598                    cluster_id: self.cluster_id.clone(),
4599                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
4600                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
4601                    }),
4602                    address: backend.address.into(),
4603                    load_balancing_parameters,
4604                    sticky_id: backend.sticky_id.clone(),
4605                    backup: backend.backup,
4606                    unix_path: backend.unix_path.clone(),
4607                })
4608                .into(),
4609            );
4610        }
4611
4612        // POST: the order stream leads with one AddCluster and emits exactly
4613        // one AddTcpFrontend per frontend and one AddBackend per backend — the
4614        // worker reconstructs the cluster topology solely from these counts.
4615        debug_assert!(
4616            matches!(
4617                v.first().and_then(|r| r.request_type.as_ref()),
4618                Some(RequestType::AddCluster(_))
4619            ),
4620            "TCP cluster orders must lead with an AddCluster"
4621        );
4622        debug_assert_eq!(
4623            v.iter()
4624                .filter(|r| matches!(
4625                    r.request_type,
4626                    Some(RequestType::AddTcpFrontend(_)) | Some(RequestType::AddUdpFrontend(_))
4627                ))
4628                .count(),
4629            self.frontends.len(),
4630            "one AddTcpFrontend or AddUdpFrontend order per configured frontend"
4631        );
4632        debug_assert_eq!(
4633            v.iter()
4634                .filter(|r| matches!(
4635                    r.request_type,
4636                    Some(RequestType::AddBackend(_)) | Some(RequestType::AddUnixBackend(_))
4637                ))
4638                .count(),
4639            self.backends.len(),
4640            "one AddBackend order per configured backend"
4641        );
4642        Ok(v)
4643    }
4644}
4645
4646#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
4647pub enum ClusterConfig {
4648    Http(HttpClusterConfig),
4649    Tcp(TcpClusterConfig),
4650}
4651
4652impl ClusterConfig {
4653    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
4654        match *self {
4655            ClusterConfig::Http(ref http) => http.generate_requests(),
4656            ClusterConfig::Tcp(ref tcp) => tcp.generate_requests(),
4657        }
4658    }
4659}
4660
4661/// Parsed from the TOML config provided by the user.
4662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
4663pub struct FileConfig {
4664    pub command_socket: Option<String>,
4665    /// Permission bits of the unix command socket, as an octal string such
4666    /// as `"0660"` or `"660"`. `None` keeps [`DEFAULT_COMMAND_SOCKET_MODE`].
4667    /// Parsed by [`parse_command_socket_mode`] when the config is built.
4668    #[serde(default)]
4669    pub command_socket_mode: Option<String>,
4670    pub command_buffer_size: Option<u64>,
4671    pub max_command_buffer_size: Option<u64>,
4672    pub max_connections: Option<usize>,
4673    pub min_buffers: Option<u64>,
4674    pub max_buffers: Option<u64>,
4675    pub buffer_size: Option<u64>,
4676    /// Slab-entries-per-connection multiplier. `None` keeps the compile-time
4677    /// default of 4. Operator-visible escape hatch for fan-out topologies
4678    /// that exceed 4 backends per session — clamped to [2, 32] at load.
4679    #[serde(default)]
4680    pub slab_entries_per_connection: Option<u64>,
4681    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
4682    /// payload accepted by the worker's `mux::auth` module. Caps the
4683    /// per-failed-auth allocation so a hostile peer cannot force the worker
4684    /// to decode arbitrarily large tokens. RFC 7617 imposes no upper bound
4685    /// — defaults to 4096, which is well above the realistic
4686    /// `username:password` shape. Operators running hardened tenants can
4687    /// lower this to e.g. 256 or 512 to bound the allocation tighter.
4688    /// Values >= `buffer_size / 3` emit a warning at config-load time
4689    /// (the credential cap shouldn't dominate the per-frontend buffer).
4690    #[serde(default)]
4691    pub basic_auth_max_credential_bytes: Option<u64>,
4692    /// Default per-(cluster, source-IP) connection limit. `None` keeps
4693    /// `0` (unlimited). Each cluster may override via its own
4694    /// `max_connections_per_ip`. The source IP is taken from the parsed
4695    /// proxy-protocol header when present, else `peer_addr`. When the
4696    /// limit is reached, HTTP requests are answered with `429 Too Many
4697    /// Requests` (with optional `Retry-After`) and TCP sessions are
4698    /// closed gracefully without dialing the backend.
4699    #[serde(default)]
4700    pub max_connections_per_ip: Option<u64>,
4701    /// Default per-(cluster, source-SUBNET) connection limit. `None`
4702    /// keeps `0` (unlimited), which leaves this limiter inert and the
4703    /// per-IP behaviour strictly unchanged. It is a SECOND, independent
4704    /// counter beside `max_connections_per_ip`: the per-IP cap serves
4705    /// the API-gateway case, the per-subnet cap the denial-of-service
4706    /// case where an attacker holding a routed prefix takes a fresh
4707    /// address per connection (sozu-proxy/sozu#1270). Both gates are
4708    /// consulted and both must admit, so "10 per IP AND 100 per /64" is
4709    /// expressible. Each cluster may override via its own
4710    /// `max_connections_per_subnet`.
4711    #[serde(default)]
4712    pub max_connections_per_subnet: Option<u64>,
4713    /// IPv4 prefix length (bits, 0-32) used to derive the subnet key.
4714    /// `None` keeps 32, which masks nothing. `/24` is the recommended
4715    /// starting point. Out-of-range values are rejected at config-load
4716    /// time rather than clamped.
4717    #[serde(default)]
4718    pub subnet_ipv4_prefix: Option<u32>,
4719    /// IPv6 prefix length (bits, 0-128) used to derive the subnet key.
4720    /// `None` keeps 128, which masks nothing. `/56` is the recommended
4721    /// starting point. An IPv4-mapped IPv6 source is canonicalised to
4722    /// IPv4 first and masked with `subnet_ipv4_prefix` instead.
4723    #[serde(default)]
4724    pub subnet_ipv6_prefix: Option<u32>,
4725    /// Default `Retry-After` header value (seconds) sent on HTTP 429
4726    /// responses. `Some(0)` or `None` keeping the default `0` omits the
4727    /// header (rendering `Retry-After: 0` invites an immediate retry that
4728    /// defeats the limit). Per-cluster overrides apply for HTTP listeners
4729    /// only. TCP listeners ignore this value (no HTTP envelope).
4730    #[serde(default)]
4731    pub retry_after: Option<u32>,
4732    /// Backend connection attempt budget of every request: how many backend
4733    /// connections a request may try, the first included, before it is
4734    /// answered `503` (HTTP) or closed (TCP). `None` keeps
4735    /// [`DEFAULT_MAX_CONNECTION_ATTEMPTS`]. Each cluster may override it with
4736    /// its own `max_connection_attempts`. Out-of-range values (see
4737    /// [`validate_max_connection_attempts`]) are rejected at config load.
4738    #[serde(default)]
4739    pub max_connection_attempts: Option<u32>,
4740    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
4741    /// zero-copy direction (Linux only, `splice` feature). `None` keeps
4742    /// the kernel default (64 KiB). Applied via `fcntl(F_SETPIPE_SZ)`;
4743    /// the kernel rounds up to a page boundary and clamps at
4744    /// `/proc/sys/fs/pipe-max-size` (default 1 MiB unprivileged). The
4745    /// realised capacity is read back via `fcntl(F_GETPIPE_SZ)` and
4746    /// drives the per-call `len` for `splice_in`. Ignored on non-Linux
4747    /// targets and on builds without the `splice` feature.
4748    #[serde(default)]
4749    pub splice_pipe_capacity_bytes: Option<u64>,
4750    /// Optional UID allowlist for command-socket requests. `None` (default)
4751    /// preserves historical behaviour: any same-UID local process can
4752    /// invoke any verb. When set, requests whose `SO_PEERCRED` UID is not
4753    /// in the list are rejected. Use to restrict mutating verbs to a
4754    /// specific operator UID even when other same-UID daemons coexist
4755    /// (CI runners, monitoring).
4756    #[serde(default)]
4757    pub command_allowed_uids: Option<Vec<u32>>,
4758    pub saved_state: Option<String>,
4759    #[serde(default)]
4760    pub automatic_state_save: Option<bool>,
4761    pub log_level: Option<String>,
4762    pub log_target: Option<String>,
4763    #[serde(default)]
4764    pub log_colored: bool,
4765    /// Dedicated file path for the control-plane audit log. When set, every
4766    /// emitted `[AUDIT]` / `Command(...)` line is also appended to this file
4767    /// opened `O_APPEND | O_CREAT` with mode `0o640` (owner read+write,
4768    /// group read, world nothing) so operators can separate the audit trail
4769    /// from the main log stream and protect it with group-scoped ACLs /
4770    /// logrotate. Independent of the standard `log_target`. `None` keeps
4771    /// audit lines routed only through the standard logger.
4772    #[serde(default)]
4773    pub audit_logs_target: Option<String>,
4774    /// Dedicated file path for a JSON-encoded mirror of the audit log.
4775    /// One JSON object per line so SIEM pipelines (Wazuh, Elastic, Loki)
4776    /// ingest without bespoke parsers. Same `O_APPEND | O_CREAT | 0o640`
4777    /// as `audit_logs_target`. `None` disables the JSON mirror.
4778    #[serde(default)]
4779    pub audit_logs_json_target: Option<String>,
4780    #[serde(default)]
4781    pub access_logs_target: Option<String>,
4782    #[serde(default)]
4783    pub access_logs_format: Option<AccessLogFormat>,
4784    #[serde(default)]
4785    pub access_logs_colored: Option<bool>,
4786    pub worker_count: Option<u16>,
4787    pub worker_automatic_restart: Option<bool>,
4788    pub metrics: Option<MetricsConfig>,
4789    pub disable_cluster_metrics: Option<bool>,
4790    pub listeners: Option<Vec<ListenerBuilder>>,
4791    pub clusters: Option<HashMap<String, FileClusterConfig>>,
4792    pub handle_process_affinity: Option<bool>,
4793    pub ctl_command_timeout: Option<u64>,
4794    pub pid_file_path: Option<String>,
4795    pub activate_listeners: Option<bool>,
4796    #[serde(default)]
4797    pub front_timeout: Option<u32>,
4798    #[serde(default)]
4799    pub back_timeout: Option<u32>,
4800    #[serde(default)]
4801    pub connect_timeout: Option<u32>,
4802    #[serde(default)]
4803    pub zombie_check_interval: Option<u32>,
4804    #[serde(default)]
4805    pub accept_queue_timeout: Option<u32>,
4806    #[serde(default)]
4807    pub evict_on_queue_full: Option<bool>,
4808    #[serde(default)]
4809    pub request_timeout: Option<u32>,
4810    #[serde(default)]
4811    pub worker_timeout: Option<u32>,
4812}
4813
4814impl FileConfig {
4815    pub fn load_from_path(path: &str) -> Result<FileConfig, ConfigError> {
4816        let data = Config::load_file(path)?;
4817
4818        let config: FileConfig = match toml::from_str(&data) {
4819            Ok(config) => config,
4820            Err(e) => {
4821                return Err(ConfigError::DeserializeToml(toml_error_diagnostic(
4822                    path, &e,
4823                )));
4824            }
4825        };
4826
4827        let mut reserved_address: HashSet<ListenerKey> = HashSet::new();
4828
4829        if let Some(listeners) = config.listeners.as_ref() {
4830            for listener in listeners.iter() {
4831                let key = ListenerKey::new(listener.address, listener.interface.as_deref());
4832                if reserved_address.contains(&key) {
4833                    return Err(ConfigError::ListenerAlreadyDeclared(key));
4834                }
4835                reserved_address.insert(key);
4836            }
4837        }
4838
4839        //FIXME: verify how clusters and listeners share addresses
4840        /*
4841        if let Some(ref clusters) = config.clusters {
4842          for (key, cluster) in clusters.iter() {
4843            if let (Some(address), Some(port)) = (cluster.ip_address.clone(), cluster.port) {
4844              let addr = (address, port);
4845              if reserved_address.contains(&addr) {
4846                println!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
4847                  key, addr.0, addr.1);
4848                return Err(Error::new(
4849                  ErrorKind::InvalidData,
4850                  format!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
4851                    key, addr.0, addr.1)));
4852              } else {
4853                reserved_address.insert(addr.clone());
4854              }
4855            }
4856          }
4857        }
4858        */
4859
4860        Ok(config)
4861    }
4862}
4863
4864/// A builder that converts [FileConfig] to [Config]
4865pub struct ConfigBuilder {
4866    file: FileConfig,
4867    known_addresses: HashMap<SocketAddr, ListenerProtocol>,
4868    expect_proxy_addresses: HashSet<SocketAddr>,
4869    /// `false` skips the resolution of `host:port` backends, see
4870    /// [`ConfigBuilder::without_backend_resolution`]
4871    resolve_backends: bool,
4872    built: Config,
4873}
4874
4875impl ConfigBuilder {
4876    /// starts building a [Config] with values from a [FileConfig], or defaults.
4877    ///
4878    /// please provide a config path, usefull for rebuilding the config later.
4879    pub fn new<S>(file_config: FileConfig, config_path: S) -> Self
4880    where
4881        S: ToString,
4882    {
4883        let built = Config {
4884            accept_queue_timeout: file_config
4885                .accept_queue_timeout
4886                .unwrap_or(DEFAULT_ACCEPT_QUEUE_TIMEOUT),
4887            evict_on_queue_full: file_config
4888                .evict_on_queue_full
4889                .unwrap_or(DEFAULT_EVICT_ON_QUEUE_FULL),
4890            activate_listeners: file_config.activate_listeners.unwrap_or(true),
4891            automatic_state_save: file_config
4892                .automatic_state_save
4893                .unwrap_or(DEFAULT_AUTOMATIC_STATE_SAVE),
4894            back_timeout: file_config.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
4895            buffer_size: file_config.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE),
4896            command_buffer_size: file_config
4897                .command_buffer_size
4898                .unwrap_or(DEFAULT_COMMAND_BUFFER_SIZE),
4899            config_path: config_path.to_string(),
4900            connect_timeout: file_config
4901                .connect_timeout
4902                .unwrap_or(DEFAULT_CONNECT_TIMEOUT),
4903            ctl_command_timeout: file_config.ctl_command_timeout.unwrap_or(1_000),
4904            front_timeout: file_config.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
4905            handle_process_affinity: file_config.handle_process_affinity.unwrap_or(false),
4906            access_logs_target: file_config.access_logs_target.clone(),
4907            audit_logs_target: file_config.audit_logs_target.clone(),
4908            audit_logs_json_target: file_config.audit_logs_json_target.clone(),
4909            access_logs_format: file_config.access_logs_format.clone(),
4910            access_logs_colored: file_config.access_logs_colored,
4911            log_level: file_config
4912                .log_level
4913                .clone()
4914                .unwrap_or_else(|| String::from("info")),
4915            log_target: file_config
4916                .log_target
4917                .clone()
4918                .unwrap_or_else(|| String::from("stdout")),
4919            log_colored: file_config.log_colored,
4920            max_buffers: file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
4921            max_command_buffer_size: file_config
4922                .max_command_buffer_size
4923                .unwrap_or(DEFAULT_MAX_COMMAND_BUFFER_SIZE),
4924            max_connections: file_config
4925                .max_connections
4926                .unwrap_or(DEFAULT_MAX_CONNECTIONS),
4927            metrics: file_config.metrics.clone(),
4928            disable_cluster_metrics: file_config
4929                .disable_cluster_metrics
4930                .unwrap_or(DEFAULT_DISABLE_CLUSTER_METRICS),
4931            min_buffers: std::cmp::min(
4932                file_config.min_buffers.unwrap_or(DEFAULT_MIN_BUFFERS),
4933                file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
4934            ),
4935            pid_file_path: file_config.pid_file_path.clone(),
4936            request_timeout: file_config
4937                .request_timeout
4938                .unwrap_or(DEFAULT_REQUEST_TIMEOUT),
4939            saved_state: file_config.saved_state.clone(),
4940            worker_automatic_restart: file_config
4941                .worker_automatic_restart
4942                .unwrap_or(DEFAULT_WORKER_AUTOMATIC_RESTART),
4943            worker_count: file_config.worker_count.unwrap_or(DEFAULT_WORKER_COUNT),
4944            zombie_check_interval: file_config
4945                .zombie_check_interval
4946                .unwrap_or(DEFAULT_ZOMBIE_CHECK_INTERVAL),
4947            worker_timeout: file_config.worker_timeout.unwrap_or(DEFAULT_WORKER_TIMEOUT),
4948            slab_entries_per_connection: file_config.slab_entries_per_connection.map(|n| {
4949                n.clamp(
4950                    ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION,
4951                    ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION,
4952                )
4953            }),
4954            command_allowed_uids: file_config.command_allowed_uids.clone(),
4955            basic_auth_max_credential_bytes: file_config.basic_auth_max_credential_bytes,
4956            max_connections_per_ip: file_config
4957                .max_connections_per_ip
4958                .unwrap_or(DEFAULT_MAX_CONNECTIONS_PER_IP),
4959            max_connections_per_subnet: file_config
4960                .max_connections_per_subnet
4961                .unwrap_or(DEFAULT_MAX_CONNECTIONS_PER_SUBNET),
4962            subnet_ipv4_prefix: file_config
4963                .subnet_ipv4_prefix
4964                .unwrap_or(DEFAULT_SUBNET_IPV4_PREFIX),
4965            subnet_ipv6_prefix: file_config
4966                .subnet_ipv6_prefix
4967                .unwrap_or(DEFAULT_SUBNET_IPV6_PREFIX),
4968            retry_after: file_config.retry_after.unwrap_or(DEFAULT_RETRY_AFTER),
4969            max_connection_attempts: file_config
4970                .max_connection_attempts
4971                .unwrap_or(DEFAULT_MAX_CONNECTION_ATTEMPTS),
4972            splice_pipe_capacity_bytes: file_config.splice_pipe_capacity_bytes,
4973            ..Default::default()
4974        };
4975
4976        // POST: the buffer free-list floor is clamped to never exceed the
4977        // ceiling — the `std::cmp::min(min_buffers, max_buffers)` above is the
4978        // sole guarantor of this, so assert it held.
4979        debug_assert!(
4980            built.min_buffers <= built.max_buffers,
4981            "min_buffers must be clamped to <= max_buffers in the builder"
4982        );
4983        // POST: an explicit slab override, if present, was clamped into the
4984        // documented [MIN, MAX] window; an absent override stays None.
4985        debug_assert!(
4986            built.slab_entries_per_connection.is_none_or(|n| {
4987                (ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION
4988                    ..=ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION)
4989                    .contains(&n)
4990            }),
4991            "a set slab_entries_per_connection must be clamped into [MIN, MAX]"
4992        );
4993
4994        Self {
4995            file: file_config,
4996            known_addresses: HashMap::new(),
4997            expect_proxy_addresses: HashSet::new(),
4998            resolve_backends: true,
4999            built,
5000        }
5001    }
5002
5003    fn push_tls_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
5004        let listener = listener.to_tls(Some(&self.built))?;
5005        self.built.https_listeners.push(listener);
5006        Ok(())
5007    }
5008
5009    fn push_http_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
5010        let listener = listener.to_http(Some(&self.built))?;
5011        self.built.http_listeners.push(listener);
5012        Ok(())
5013    }
5014
5015    fn push_tcp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
5016        let listener = listener.to_tcp(Some(&self.built))?;
5017        self.built.tcp_listeners.push(listener);
5018        Ok(())
5019    }
5020
5021    fn push_udp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
5022        let listener = listener.to_udp(Some(&self.built))?;
5023        self.built.udp_listeners.push(listener);
5024        Ok(())
5025    }
5026
5027    fn populate_listeners(&mut self, listeners: Vec<ListenerBuilder>) -> Result<(), ConfigError> {
5028        let mut declared: HashSet<ListenerKey> = HashSet::new();
5029        for listener in listeners.iter() {
5030            // A listener is identified by (address, interface): two listeners
5031            // may share an address on different interfaces, never both.
5032            let key = ListenerKey::new(listener.address, listener.interface.as_deref());
5033            if !declared.insert(key.clone()) {
5034                if key.interface.is_none() {
5035                    return Err(ConfigError::ListenerAddressAlreadyInUse(listener.address));
5036                }
5037                return Err(ConfigError::ListenerAlreadyDeclared(key));
5038            }
5039
5040            let protocol = listener
5041                .protocol
5042                .ok_or(ConfigError::Missing(MissingKind::Protocol))?;
5043
5044            // Frontends name their listener by address alone, and resolve its
5045            // protocol from `known_addresses`: listeners sharing an address on
5046            // different interfaces must agree on it.
5047            if let Some(known) = self.known_addresses.get(&listener.address)
5048                && *known != protocol
5049            {
5050                return Err(ConfigError::ListenerProtocolMismatchOnAddress(
5051                    listener.address,
5052                ));
5053            }
5054
5055            self.known_addresses.insert(listener.address, protocol);
5056            if listener.expect_proxy == Some(true) {
5057                self.expect_proxy_addresses.insert(listener.address);
5058            }
5059
5060            if listener.public_address.is_some() && listener.expect_proxy == Some(true) {
5061                return Err(ConfigError::Incompatible {
5062                    object: ObjectKind::Listener,
5063                    id: listener.address.to_string(),
5064                    kind: IncompatibilityKind::PublicAddress,
5065                });
5066            }
5067
5068            match protocol {
5069                ListenerProtocol::Https => self.push_tls_listener(listener.clone())?,
5070                ListenerProtocol::Http => self.push_http_listener(listener.clone())?,
5071                ListenerProtocol::Tcp => self.push_tcp_listener(listener.clone())?,
5072                ListenerProtocol::Udp => self.push_udp_listener(listener.clone())?,
5073            }
5074        }
5075        Ok(())
5076    }
5077
5078    fn populate_clusters(
5079        &mut self,
5080        mut file_cluster_configs: HashMap<String, FileClusterConfig>,
5081    ) -> Result<(), ConfigError> {
5082        for (id, file_cluster_config) in file_cluster_configs.drain() {
5083            let resolver: Option<&BackendResolver<'_>> =
5084                self.resolve_backends.then_some(&system_resolver);
5085            let mut cluster_config = file_cluster_config.to_cluster_config_with(
5086                id.as_str(),
5087                &self.expect_proxy_addresses,
5088                resolver,
5089            )?;
5090
5091            match cluster_config {
5092                ClusterConfig::Http(ref mut http) => {
5093                    for frontend in http.frontends.iter_mut() {
5094                        match self.known_addresses.get(&frontend.address) {
5095                            Some(ListenerProtocol::Tcp) => {
5096                                return Err(ConfigError::WrongFrontendProtocol(
5097                                    ListenerProtocol::Tcp,
5098                                ));
5099                            }
5100                            Some(ListenerProtocol::Udp) => {
5101                                return Err(ConfigError::WrongFrontendProtocol(
5102                                    ListenerProtocol::Udp,
5103                                ));
5104                            }
5105                            Some(ListenerProtocol::Http) => {
5106                                if frontend.certificate.is_some() {
5107                                    return Err(ConfigError::WrongFrontendProtocol(
5108                                        ListenerProtocol::Http,
5109                                    ));
5110                                }
5111                            }
5112                            Some(ListenerProtocol::Https) => {
5113                                if frontend.certificate.is_none() {
5114                                    if let Some(https_listener) =
5115                                        self.built.https_listeners.iter().find(|listener| {
5116                                            listener.address == frontend.address.into()
5117                                                && listener.certificate.is_some()
5118                                        })
5119                                    {
5120                                        //println!("using listener certificate for {:}", frontend.address);
5121                                        frontend
5122                                            .certificate
5123                                            .clone_from(&https_listener.certificate);
5124                                        frontend.certificate_chain =
5125                                            Some(https_listener.certificate_chain.clone());
5126                                        frontend.key.clone_from(&https_listener.key);
5127                                    }
5128                                    if frontend.certificate.is_none() {
5129                                        debug!("known addresses: {:?}", self.known_addresses);
5130                                        debug!("frontend: {:?}", frontend);
5131                                        return Err(ConfigError::WrongFrontendProtocol(
5132                                            ListenerProtocol::Https,
5133                                        ));
5134                                    }
5135                                }
5136                            }
5137                            None => {
5138                                // create a default listener for that front
5139                                let file_listener_protocol = if frontend.certificate.is_some() {
5140                                    self.push_tls_listener(ListenerBuilder::new(
5141                                        frontend.address.into(),
5142                                        ListenerProtocol::Https,
5143                                    ))?;
5144
5145                                    ListenerProtocol::Https
5146                                } else {
5147                                    self.push_http_listener(ListenerBuilder::new(
5148                                        frontend.address.into(),
5149                                        ListenerProtocol::Http,
5150                                    ))?;
5151
5152                                    ListenerProtocol::Http
5153                                };
5154                                self.known_addresses
5155                                    .insert(frontend.address, file_listener_protocol);
5156                            }
5157                        }
5158                    }
5159                }
5160                ClusterConfig::Tcp(ref mut tcp) => {
5161                    //FIXME: verify that different TCP clusters do not request the same address
5162                    for frontend in tcp.frontends.iter_mut() {
5163                        match self.known_addresses.get(&frontend.address) {
5164                            Some(ListenerProtocol::Http) | Some(ListenerProtocol::Https) => {
5165                                return Err(ConfigError::WrongFrontendProtocol(
5166                                    ListenerProtocol::Http,
5167                                ));
5168                            }
5169                            Some(ListenerProtocol::Udp) => {
5170                                // A `protocol = "tcp"` cluster whose frontend
5171                                // points at a `protocol = "udp"` listener is a
5172                                // UDP cluster (datagram knobs under
5173                                // `[clusters.<id>.udp]`). Mark the frontend so
5174                                // `generate_requests` emits `AddUdpFrontend`.
5175                                frontend.udp = true;
5176                                // Datagrams are relayed over UDP sockets: a
5177                                // UNIX stream backend cannot carry them.
5178                                if let Some(backend) =
5179                                    tcp.backends.iter().find(|b| b.unix_path.is_some())
5180                                {
5181                                    return Err(ConfigError::InvalidBackendUnixPath {
5182                                        cluster_id: tcp.cluster_id.to_owned(),
5183                                        address: backend.address,
5184                                        reason: "a UDP cluster cannot use unix_path backends",
5185                                    });
5186                                }
5187                            }
5188                            Some(ListenerProtocol::Tcp) => {}
5189                            None => {
5190                                // create a default listener for that front
5191                                self.push_tcp_listener(ListenerBuilder::new(
5192                                    frontend.address.into(),
5193                                    ListenerProtocol::Tcp,
5194                                ))?;
5195                                self.known_addresses
5196                                    .insert(frontend.address, ListenerProtocol::Tcp);
5197                            }
5198                        }
5199                    }
5200                }
5201            }
5202
5203            self.built.clusters.insert(id, cluster_config);
5204        }
5205        Ok(())
5206    }
5207
5208    /// Builds the [`Config`] without resolving `host:port` backends, for a
5209    /// caller that only reads its scalar settings (the control commands).
5210    /// Everything else is validated as usual.
5211    pub fn without_backend_resolution(mut self) -> Self {
5212        self.resolve_backends = false;
5213        self
5214    }
5215
5216    /// Builds a [`Config`], populated with listeners and clusters
5217    pub fn into_config(&mut self) -> Result<Config, ConfigError> {
5218        // Subnet prefix lengths must fit their address family. Checked
5219        // here rather than clamped in `ConfigBuilder::new` (which cannot
5220        // fail) so a typo such as `subnet_ipv4_prefix = 64` stops the
5221        // load instead of quietly enforcing /32.
5222        if self.built.subnet_ipv4_prefix > MAX_SUBNET_IPV4_PREFIX {
5223            return Err(ConfigError::InvalidSubnetPrefix {
5224                key: "subnet_ipv4_prefix",
5225                family: "IPv4",
5226                value: self.built.subnet_ipv4_prefix,
5227                maximum: MAX_SUBNET_IPV4_PREFIX,
5228            });
5229        }
5230        if self.built.subnet_ipv6_prefix > MAX_SUBNET_IPV6_PREFIX {
5231            return Err(ConfigError::InvalidSubnetPrefix {
5232                key: "subnet_ipv6_prefix",
5233                family: "IPv6",
5234                value: self.built.subnet_ipv6_prefix,
5235                maximum: MAX_SUBNET_IPV6_PREFIX,
5236            });
5237        }
5238        validate_max_connection_attempts(self.built.max_connection_attempts).map_err(|reason| {
5239            ConfigError::InvalidMaxConnectionAttempts {
5240                cluster_id: None,
5241                value: self.built.max_connection_attempts,
5242                reason,
5243            }
5244        })?;
5245
5246        if let Some(listeners) = &self.file.listeners {
5247            self.populate_listeners(listeners.clone())?;
5248        }
5249
5250        if let Some(file_cluster_configs) = &self.file.clusters {
5251            self.populate_clusters(file_cluster_configs.clone())?;
5252        }
5253
5254        // TCP SNI/ALPN routing invariants (sozu-proxy/sozu#1279). Collect
5255        // every TCP frontend's (address, sni, alpn) across all clusters,
5256        // then validate:
5257        //   (c) a listener must not mix a no-SNI frontend with any
5258        //       SNI-scoped frontend — an SNI-enabled listener prereads the
5259        //       ClientHello, so a raw-TCP fallback on the same address is
5260        //       unreachable for clients that don't send SNI and ambiguous
5261        //       for those that do;
5262        //   (b) two frontends on the same (address, sni) must not share an
5263        //       ALPN protocol, and at most one may leave alpn empty (the
5264        //       catch-all) — otherwise routing on that listener would
5265        //       depend on iteration order rather than configuration;
5266        //   (d)/(e) sni_preread_timeout must not exceed front_timeout, and
5267        //       sni_preread_max_bytes must not exceed buffer_size, on any
5268        //       listener an SNI frontend targets. Both are gated on
5269        //       SNI-presence (not just any TCP listener) so a legacy
5270        //       TCP-only config with a low front_timeout or small
5271        //       buffer_size — set long before this feature existed and
5272        //       never opting into SNI — keeps loading byte-identically.
5273        type SniAlpnByAddress = HashMap<SocketAddr, Vec<(Option<String>, Vec<String>)>>;
5274        let mut frontends_by_address: SniAlpnByAddress = HashMap::new();
5275        let mut addresses_with_no_sni_frontend: HashSet<SocketAddr> = HashSet::new();
5276        for cluster in self.built.clusters.values() {
5277            if let ClusterConfig::Tcp(tcp) = cluster {
5278                for frontend in &tcp.frontends {
5279                    // A `protocol = "tcp"` cluster frontend resolved against a
5280                    // `protocol = "udp"` listener (`frontend.udp`, set in
5281                    // `populate_clusters`) emits `AddUdpFrontend`, which
5282                    // carries no `sni`/`alpn` on the wire — it is not a real
5283                    // SNI-preread TCP frontend and must not participate in
5284                    // these TCP-only invariants.
5285                    if frontend.udp {
5286                        continue;
5287                    }
5288                    if frontend.sni.is_none() {
5289                        addresses_with_no_sni_frontend.insert(frontend.address);
5290                    }
5291                    frontends_by_address
5292                        .entry(frontend.address)
5293                        .or_default()
5294                        .push((frontend.sni.clone(), frontend.alpn.clone()));
5295                }
5296            }
5297        }
5298
5299        let mut addresses_with_sni_frontend: HashSet<SocketAddr> = HashSet::new();
5300        for (address, frontends) in &frontends_by_address {
5301            if !frontends.iter().any(|(sni, _)| sni.is_some()) {
5302                continue;
5303            }
5304            addresses_with_sni_frontend.insert(*address);
5305
5306            if addresses_with_no_sni_frontend.contains(address) {
5307                return Err(ConfigError::TcpListenerMixesSniAndNoSni { address: *address });
5308            }
5309
5310            let mut alpn_lists_by_sni: HashMap<Option<String>, Vec<&Vec<String>>> = HashMap::new();
5311            for (sni, alpn) in frontends {
5312                alpn_lists_by_sni.entry(sni.clone()).or_default().push(alpn);
5313            }
5314            for (sni, alpn_lists) in alpn_lists_by_sni {
5315                let mut seen_protocols: HashSet<&str> = HashSet::new();
5316                let mut catch_all_count = 0usize;
5317                for alpn in alpn_lists {
5318                    if alpn.is_empty() {
5319                        catch_all_count += 1;
5320                        if catch_all_count > 1 {
5321                            return Err(ConfigError::TcpFrontendMultipleAlpnCatchAll {
5322                                address: *address,
5323                                sni: sni.clone(),
5324                            });
5325                        }
5326                        continue;
5327                    }
5328                    for protocol in alpn {
5329                        if !seen_protocols.insert(protocol.as_str()) {
5330                            return Err(ConfigError::TcpFrontendAlpnOverlap {
5331                                address: *address,
5332                                sni: sni.clone(),
5333                                protocol: protocol.clone(),
5334                            });
5335                        }
5336                    }
5337                }
5338            }
5339        }
5340
5341        for listener in &self.built.tcp_listeners {
5342            let address: SocketAddr = listener.address.into();
5343            if !addresses_with_sni_frontend.contains(&address) {
5344                continue;
5345            }
5346            let sni_preread_timeout = listener
5347                .sni_preread_timeout
5348                .unwrap_or(DEFAULT_SNI_PREREAD_TIMEOUT);
5349            if sni_preread_timeout > listener.front_timeout {
5350                return Err(ConfigError::SniPrereadTimeoutExceedsFrontTimeout {
5351                    address,
5352                    sni_preread_timeout,
5353                    front_timeout: listener.front_timeout,
5354                });
5355            }
5356            let sni_preread_max_bytes = listener
5357                .sni_preread_max_bytes
5358                .unwrap_or(DEFAULT_SNI_PREREAD_MAX_BYTES);
5359            if sni_preread_max_bytes < MIN_SNI_PREREAD_MAX_BYTES {
5360                return Err(ConfigError::SniPrereadMaxBytesTooSmall {
5361                    address,
5362                    sni_preread_max_bytes,
5363                    minimum: MIN_SNI_PREREAD_MAX_BYTES,
5364                });
5365            }
5366            if u64::from(sni_preread_max_bytes) > self.built.buffer_size {
5367                return Err(ConfigError::SniPrereadMaxBytesExceedsBufferSize {
5368                    address,
5369                    sni_preread_max_bytes,
5370                    buffer_size: self.built.buffer_size,
5371                });
5372            }
5373        }
5374
5375        // RFC 9113 §6.5.2 + §4.1: the H2 mux must accept up to
5376        // SETTINGS_MAX_FRAME_SIZE (16 384) + 9-byte frame header in a single
5377        // kawa buffer. If any HTTPS listener advertises "h2" in its ALPN list
5378        // and the global buffer_size is below H2_MIN_BUFFER_SIZE, the mux
5379        // deadlocks on full-size DATA / HEADERS / CONTINUATION frames until
5380        // the session timeout fires. Reject at config load so the failure
5381        // mode surfaces at boot, not under traffic.
5382        // Long-form rationale: `lib/src/protocol/mux/LIFECYCLE.md`.
5383        let h2_listeners = self
5384            .built
5385            .https_listeners
5386            .iter()
5387            .filter(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
5388            .count();
5389        if h2_listeners > 0 && self.built.buffer_size < H2_MIN_BUFFER_SIZE {
5390            return Err(ConfigError::BufferSizeTooSmallForH2 {
5391                buffer_size: self.built.buffer_size,
5392                minimum: H2_MIN_BUFFER_SIZE,
5393                listeners: h2_listeners,
5394            });
5395        }
5396
5397        // The command channel (`Channel::new`, `command/src/channel.rs`) allocates its
5398        // initial buffer at `command_buffer_size` and must never let it exceed
5399        // `max_command_buffer_size` -- every growth/shrink path on that channel reasons
5400        // against that ceiling. Reject at config load, where the error can name both
5401        // keys and both values, rather than let a misconfigured pair reach the real
5402        // master<->worker channel (sozu-proxy/sozu#1416).
5403        if self.built.command_buffer_size > self.built.max_command_buffer_size {
5404            return Err(ConfigError::CommandBufferSizeExceedsMax {
5405                command_buffer_size: self.built.command_buffer_size,
5406                max_command_buffer_size: self.built.max_command_buffer_size,
5407            });
5408        }
5409
5410        // Warn (no hard reject) when the configured Basic-auth credential
5411        // cap is large enough to dominate the per-frontend buffer. The
5412        // worker copies a decoded credential into a transient allocation
5413        // sized by this cap; values >= 33% of `buffer_size` mean a single
5414        // failed-auth attempt can hold a third of the buffer's worth of
5415        // bytes, which combined with in-flight request/response framing
5416        // pushes the buffer toward back-pressure under load. Log only —
5417        // operators with deliberate threat models may choose this
5418        // trade-off, but the surprise needs to be visible.
5419        if let Some(cap) = self.built.basic_auth_max_credential_bytes {
5420            let third = self.built.buffer_size / 3;
5421            if cap >= third {
5422                warn!(
5423                    "basic_auth_max_credential_bytes = {} is >= buffer_size / 3 ({}); \
5424                     a hostile peer can pin ~33% of the per-frontend buffer per failed auth \
5425                     attempt. Consider lowering basic_auth_max_credential_bytes (typical \
5426                     credentials are <100 bytes) or raising buffer_size.",
5427                    cap, third
5428                );
5429            }
5430        }
5431
5432        // The eviction batch is `(max_connections / 100).max(1)` — a 1% ratio
5433        // by design. Below 100 connections the floor of 1 means each cap
5434        // event evicts a larger share than 1% of capacity (e.g. 4% at
5435        // max_connections=25), which can surprise an operator who reads the
5436        // knob as "1% per round". Warn at config load so the discrepancy is
5437        // visible at boot, not under traffic.
5438        if self.built.evict_on_queue_full && self.built.max_connections < 100 {
5439            let pct = 100usize.div_ceil(self.built.max_connections);
5440            warn!(
5441                "evict_on_queue_full enabled with max_connections = {}; the eviction batch \
5442                 clamps to 1, equivalent to ~{}% of capacity per cap event (the knob is \
5443                 documented as 1%). Confirm this is intended.",
5444                self.built.max_connections, pct
5445            );
5446        }
5447
5448        let command_socket_path = self.file.command_socket.clone().unwrap_or({
5449            let mut path = env::current_dir().map_err(|e| ConfigError::Env(e.to_string()))?;
5450            path.push("sozu.sock");
5451            let verified_path = path
5452                .to_str()
5453                .ok_or(ConfigError::InvalidPath(path.clone()))?;
5454            verified_path.to_owned()
5455        });
5456
5457        if let (None, Some(true)) = (&self.file.saved_state, &self.file.automatic_state_save) {
5458            return Err(ConfigError::Missing(MissingKind::SavedState));
5459        }
5460
5461        let command_socket_mode = match self.file.command_socket_mode.as_deref() {
5462            Some(mode) => parse_command_socket_mode(mode)?,
5463            None => DEFAULT_COMMAND_SOCKET_MODE,
5464        };
5465
5466        let config = Config {
5467            command_socket: command_socket_path,
5468            command_socket_mode,
5469            ..self.built.clone()
5470        };
5471
5472        // POST: a successfully built config satisfies the buffer-pool
5473        // invariants every worker relies on.
5474        // 1. min_buffers <= max_buffers — guaranteed by the `std::cmp::min`
5475        //    clamp in `new`; a violation would let a worker size its free-list
5476        //    floor above its ceiling.
5477        debug_assert!(
5478            config.min_buffers <= config.max_buffers,
5479            "min_buffers must not exceed max_buffers"
5480        );
5481        // 2. If any HTTPS listener advertises h2 in its ALPN, the global
5482        //    buffer_size is at least the H2 minimum — otherwise the early
5483        //    return above would have produced a BufferSizeTooSmallForH2 error
5484        //    rather than this Ok. (Recomputed here so the assert is independent
5485        //    of the local `h2_listeners` binding above.)
5486        debug_assert!(
5487            !config
5488                .https_listeners
5489                .iter()
5490                .any(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
5491                || config.buffer_size >= H2_MIN_BUFFER_SIZE,
5492            "an h2-advertising config must satisfy the H2 minimum buffer size"
5493        );
5494        Ok(config)
5495    }
5496}
5497
5498/// Sōzu configuration, populated with clusters and listeners.
5499///
5500/// This struct is used on startup to generate `WorkerRequest`s
5501#[derive(Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
5502pub struct Config {
5503    pub config_path: String,
5504    pub command_socket: String,
5505    /// Permission bits applied to the command socket after `bind(2)`, at most
5506    /// [`MAX_COMMAND_SOCKET_MODE`]. An upgrade snapshot written by a main
5507    /// process that predates the key deserializes to
5508    /// [`DEFAULT_COMMAND_SOCKET_MODE`].
5509    #[serde(default = "default_command_socket_mode")]
5510    pub command_socket_mode: u32,
5511    pub command_buffer_size: u64,
5512    pub max_command_buffer_size: u64,
5513    pub max_connections: usize,
5514    pub min_buffers: u64,
5515    pub max_buffers: u64,
5516    pub buffer_size: u64,
5517    pub saved_state: Option<String>,
5518    #[serde(default)]
5519    pub automatic_state_save: bool,
5520    pub log_level: String,
5521    pub log_target: String,
5522    pub log_colored: bool,
5523    /// Optional dedicated file path for the control-plane audit log. See
5524    /// `FileConfig::audit_logs_target` for rationale.
5525    #[serde(default)]
5526    pub audit_logs_target: Option<String>,
5527    /// Optional JSON mirror of the audit log; see
5528    /// `FileConfig::audit_logs_json_target`.
5529    #[serde(default)]
5530    pub audit_logs_json_target: Option<String>,
5531    #[serde(default)]
5532    pub access_logs_target: Option<String>,
5533    pub access_logs_format: Option<AccessLogFormat>,
5534    pub access_logs_colored: Option<bool>,
5535    pub worker_count: u16,
5536    pub worker_automatic_restart: bool,
5537    pub metrics: Option<MetricsConfig>,
5538    #[serde(default = "default_disable_cluster_metrics")]
5539    pub disable_cluster_metrics: bool,
5540    pub http_listeners: Vec<HttpListenerConfig>,
5541    pub https_listeners: Vec<HttpsListenerConfig>,
5542    pub tcp_listeners: Vec<TcpListenerConfig>,
5543    #[serde(default)]
5544    pub udp_listeners: Vec<UdpListenerConfig>,
5545    pub clusters: HashMap<String, ClusterConfig>,
5546    pub handle_process_affinity: bool,
5547    pub ctl_command_timeout: u64,
5548    pub pid_file_path: Option<String>,
5549    pub activate_listeners: bool,
5550    #[serde(default = "default_front_timeout")]
5551    pub front_timeout: u32,
5552    #[serde(default = "default_back_timeout")]
5553    pub back_timeout: u32,
5554    #[serde(default = "default_connect_timeout")]
5555    pub connect_timeout: u32,
5556    #[serde(default = "default_zombie_check_interval")]
5557    pub zombie_check_interval: u32,
5558    #[serde(default = "default_accept_queue_timeout")]
5559    pub accept_queue_timeout: u32,
5560    #[serde(default = "default_evict_on_queue_full")]
5561    pub evict_on_queue_full: bool,
5562    #[serde(default = "default_request_timeout")]
5563    pub request_timeout: u32,
5564    #[serde(default = "default_worker_timeout")]
5565    pub worker_timeout: u32,
5566    /// Slab-entries-per-connection multiplier exposed for operators with
5567    /// fan-out topologies that exceed the default 4 backends per session.
5568    /// `None` means the default (4) applies; set values are clamped to
5569    /// [`ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION`,
5570    /// `ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION`] = [2, 32]. Slab
5571    /// capacity is `10 + slab_entries_per_connection * max_connections`.
5572    #[serde(default)]
5573    pub slab_entries_per_connection: Option<u64>,
5574    /// Optional allowlist of UIDs permitted to invoke command-socket
5575    /// requests. `None` keeps the historical "any same-UID local process"
5576    /// behaviour. When `Some`, every request whose `SO_PEERCRED` UID is
5577    /// not in the list is rejected before reaching dispatch.
5578    #[serde(default)]
5579    pub command_allowed_uids: Option<Vec<u32>>,
5580    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
5581    /// payload accepted by `mux::auth`. `None` keeps the compile-time
5582    /// default of 4096. Set once on each worker at boot via
5583    /// [`ServerConfig::basic_auth_max_credential_bytes`].
5584    #[serde(default)]
5585    pub basic_auth_max_credential_bytes: Option<u64>,
5586    /// Default per-(cluster, source-IP) connection limit. `0` means
5587    /// unlimited. Each cluster may override via its own
5588    /// `max_connections_per_ip`. Source IP attribution honours the
5589    /// proxy-protocol header when present. UDP listeners ignore it: only a
5590    /// cluster's own value limits UDP flows.
5591    #[serde(default = "default_max_connections_per_ip")]
5592    pub max_connections_per_ip: u64,
5593    /// Default per-(cluster, source-SUBNET) connection limit. `0` means
5594    /// unlimited and is the default, so the subnet limiter is inert
5595    /// until an operator turns it on. An independent second counter
5596    /// beside `max_connections_per_ip`, not a replacement: both gates
5597    /// must admit. Each cluster may override.
5598    #[serde(default = "default_max_connections_per_subnet")]
5599    pub max_connections_per_subnet: u64,
5600    /// IPv4 prefix length (bits, 0-32) defining the subnet key. 32
5601    /// (default) masks nothing.
5602    #[serde(default = "default_subnet_ipv4_prefix")]
5603    pub subnet_ipv4_prefix: u32,
5604    /// IPv6 prefix length (bits, 0-128) defining the subnet key. 128
5605    /// (default) masks nothing.
5606    #[serde(default = "default_subnet_ipv6_prefix")]
5607    pub subnet_ipv6_prefix: u32,
5608    /// Default `Retry-After` header value (seconds) emitted on HTTP 429
5609    /// responses. `0` omits the header.
5610    #[serde(default = "default_retry_after")]
5611    pub retry_after: u32,
5612    /// Backend connection attempt budget of every request, the first attempt
5613    /// included. Each cluster may override it.
5614    #[serde(default = "default_max_connection_attempts")]
5615    pub max_connection_attempts: u32,
5616    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
5617    /// zero-copy direction. `None` keeps the kernel default of 64 KiB.
5618    /// Applied via `fcntl(F_SETPIPE_SZ)` per pipe at `SplicePipe::new`;
5619    /// the kernel rounds up to a page boundary and clamps at
5620    /// `/proc/sys/fs/pipe-max-size`. Linux-only; ignored on builds
5621    /// without the `splice` feature.
5622    #[serde(default)]
5623    pub splice_pipe_capacity_bytes: Option<u64>,
5624}
5625
5626fn default_command_socket_mode() -> u32 {
5627    DEFAULT_COMMAND_SOCKET_MODE
5628}
5629
5630/// Parse `command_socket_mode`: one to four octal digits, a leading `0`
5631/// allowed (`"0660"` and `"660"` are the same mode), no `0o` prefix and no
5632/// sign. The value must not exceed [`MAX_COMMAND_SOCKET_MODE`], which refuses
5633/// setuid, setgid and the sticky bit, and must keep the owner's read and
5634/// write bits: without them the proxy's own user could no longer connect to
5635/// run `status`, `reload` or `upgrade`.
5636pub fn parse_command_socket_mode(value: &str) -> Result<u32, ConfigError> {
5637    let invalid = |reason| ConfigError::InvalidCommandSocketMode {
5638        value: value.to_owned(),
5639        reason,
5640    };
5641    if value.is_empty() || value.len() > 4 || !value.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
5642        return Err(invalid(
5643            "expected one to four octal digits, such as \"0660\" or \"660\"",
5644        ));
5645    }
5646    let mode =
5647        u32::from_str_radix(value, 8).map_err(|_| invalid("expected one to four octal digits"))?;
5648    if mode > MAX_COMMAND_SOCKET_MODE {
5649        return Err(invalid(
5650            "only permission bits (at most 0777) are allowed; setuid, setgid and sticky are refused",
5651        ));
5652    }
5653    if mode & 0o600 != 0o600 {
5654        return Err(invalid("the owner must keep read and write (0600)"));
5655    }
5656    Ok(mode)
5657}
5658
5659fn default_front_timeout() -> u32 {
5660    DEFAULT_FRONT_TIMEOUT
5661}
5662
5663fn default_back_timeout() -> u32 {
5664    DEFAULT_BACK_TIMEOUT
5665}
5666
5667fn default_connect_timeout() -> u32 {
5668    DEFAULT_CONNECT_TIMEOUT
5669}
5670
5671fn default_request_timeout() -> u32 {
5672    DEFAULT_REQUEST_TIMEOUT
5673}
5674
5675fn default_zombie_check_interval() -> u32 {
5676    DEFAULT_ZOMBIE_CHECK_INTERVAL
5677}
5678
5679fn default_accept_queue_timeout() -> u32 {
5680    DEFAULT_ACCEPT_QUEUE_TIMEOUT
5681}
5682
5683fn default_evict_on_queue_full() -> bool {
5684    DEFAULT_EVICT_ON_QUEUE_FULL
5685}
5686
5687fn default_disable_cluster_metrics() -> bool {
5688    DEFAULT_DISABLE_CLUSTER_METRICS
5689}
5690
5691fn default_worker_timeout() -> u32 {
5692    DEFAULT_WORKER_TIMEOUT
5693}
5694
5695fn default_max_connections_per_ip() -> u64 {
5696    DEFAULT_MAX_CONNECTIONS_PER_IP
5697}
5698
5699fn default_max_connections_per_subnet() -> u64 {
5700    DEFAULT_MAX_CONNECTIONS_PER_SUBNET
5701}
5702
5703fn default_subnet_ipv4_prefix() -> u32 {
5704    DEFAULT_SUBNET_IPV4_PREFIX
5705}
5706
5707fn default_subnet_ipv6_prefix() -> u32 {
5708    DEFAULT_SUBNET_IPV6_PREFIX
5709}
5710
5711fn default_retry_after() -> u32 {
5712    DEFAULT_RETRY_AFTER
5713}
5714
5715fn default_max_connection_attempts() -> u32 {
5716    DEFAULT_MAX_CONNECTION_ATTEMPTS
5717}
5718
5719impl Config {
5720    /// Parse a TOML file and build a config out of it
5721    pub fn load_from_path(path: &str) -> Result<Config, ConfigError> {
5722        let file_config = FileConfig::load_from_path(path)?;
5723
5724        let mut config = ConfigBuilder::new(file_config, path).into_config()?;
5725
5726        // replace saved_state with a verified path
5727        config.saved_state = config.saved_state_path()?;
5728
5729        Ok(config)
5730    }
5731
5732    /// Like [`Config::load_from_path`], but `host:port` backends are not
5733    /// resolved: for the control commands, which read the scalar settings and
5734    /// must not depend on DNS. The rest of the file is validated as usual.
5735    pub fn load_from_path_for_control(path: &str) -> Result<Config, ConfigError> {
5736        let file_config = FileConfig::load_from_path(path)?;
5737
5738        let mut config = ConfigBuilder::new(file_config, path)
5739            .without_backend_resolution()
5740            .into_config()?;
5741        config.saved_state = config.saved_state_path()?;
5742
5743        Ok(config)
5744    }
5745
5746    /// yields requests intended to recreate a proxy that match the config
5747    pub fn generate_config_messages(&self) -> Result<Vec<WorkerRequest>, ConfigError> {
5748        let mut v = Vec::new();
5749        let mut count = 0u8;
5750
5751        for listener in &self.http_listeners {
5752            v.push(WorkerRequest {
5753                id: format!("CONFIG-{count}"),
5754                content: RequestType::AddHttpListener(listener.clone()).into(),
5755            });
5756            count += 1;
5757        }
5758
5759        for listener in &self.https_listeners {
5760            v.push(WorkerRequest {
5761                id: format!("CONFIG-{count}"),
5762                content: RequestType::add_https_listener(listener.clone()).into(),
5763            });
5764            count += 1;
5765        }
5766
5767        for listener in &self.tcp_listeners {
5768            v.push(WorkerRequest {
5769                id: format!("CONFIG-{count}"),
5770                content: RequestType::AddTcpListener(listener.clone()).into(),
5771            });
5772            count += 1;
5773        }
5774
5775        for listener in &self.udp_listeners {
5776            v.push(WorkerRequest {
5777                id: format!("CONFIG-{count}"),
5778                content: RequestType::AddUdpListener(listener.clone()).into(),
5779            });
5780            count += 1;
5781        }
5782
5783        for cluster in self.clusters.values() {
5784            let mut orders = cluster.generate_requests()?;
5785            for content in orders.drain(..) {
5786                v.push(WorkerRequest {
5787                    id: format!("CONFIG-{count}"),
5788                    content,
5789                });
5790                count += 1;
5791            }
5792        }
5793
5794        if self.activate_listeners {
5795            for listener in &self.http_listeners {
5796                v.push(WorkerRequest {
5797                    id: format!("CONFIG-{count}"),
5798                    content: RequestType::ActivateListener(ActivateListener {
5799                        interface: listener.interface.clone(),
5800                        address: listener.address,
5801                        proxy: ListenerType::Http.into(),
5802                        from_scm: false,
5803                    })
5804                    .into(),
5805                });
5806                count += 1;
5807            }
5808
5809            for listener in &self.https_listeners {
5810                v.push(WorkerRequest {
5811                    id: format!("CONFIG-{count}"),
5812                    content: RequestType::ActivateListener(ActivateListener {
5813                        interface: listener.interface.clone(),
5814                        address: listener.address,
5815                        proxy: ListenerType::Https.into(),
5816                        from_scm: false,
5817                    })
5818                    .into(),
5819                });
5820                count += 1;
5821            }
5822
5823            for listener in &self.tcp_listeners {
5824                v.push(WorkerRequest {
5825                    id: format!("CONFIG-{count}"),
5826                    content: RequestType::ActivateListener(ActivateListener {
5827                        interface: listener.interface.clone(),
5828                        address: listener.address,
5829                        proxy: ListenerType::Tcp.into(),
5830                        from_scm: false,
5831                    })
5832                    .into(),
5833                });
5834                count += 1;
5835            }
5836
5837            for listener in &self.udp_listeners {
5838                v.push(WorkerRequest {
5839                    id: format!("CONFIG-{count}"),
5840                    content: RequestType::ActivateListener(ActivateListener {
5841                        interface: listener.interface.clone(),
5842                        address: listener.address,
5843                        proxy: ListenerType::Udp.into(),
5844                        from_scm: false,
5845                    })
5846                    .into(),
5847                });
5848                count += 1;
5849            }
5850        }
5851
5852        if self.disable_cluster_metrics {
5853            v.push(WorkerRequest {
5854                id: format!("CONFIG-{count}"),
5855                content: RequestType::ConfigureMetrics(MetricsConfiguration::Disabled.into())
5856                    .into(),
5857            });
5858            // count += 1; // uncomment if code is added below
5859        }
5860
5861        Ok(v)
5862    }
5863
5864    /// Get the path of the UNIX socket used to communicate with Sōzu
5865    pub fn command_socket_path(&self) -> Result<String, ConfigError> {
5866        let config_path_buf = PathBuf::from(self.config_path.clone());
5867        let mut config_dir = config_path_buf
5868            .parent()
5869            .ok_or(ConfigError::NoFileParent(
5870                config_path_buf.to_string_lossy().to_string(),
5871            ))?
5872            .to_path_buf();
5873
5874        let socket_path = PathBuf::from(self.command_socket.clone());
5875
5876        let mut socket_parent_dir = match socket_path.parent() {
5877            // if the socket path is of the form "./sozu.sock",
5878            // then the parent is the directory where config.toml is situated
5879            None => config_dir,
5880            Some(path) => {
5881                // concatenate the config directory and the relative path of the socket
5882                config_dir.push(path);
5883                // canonicalize to remove double dots like /path/to/config/directory/../../path/to/socket/directory/
5884                config_dir.canonicalize().map_err(|io_error| {
5885                    ConfigError::SocketPathError(format!(
5886                        "Could not canonicalize path {config_dir:?}: {io_error}"
5887                    ))
5888                })?
5889            }
5890        };
5891
5892        let socket_name = socket_path
5893            .file_name()
5894            .ok_or(ConfigError::SocketPathError(format!(
5895                "could not get command socket file name from {socket_path:?}"
5896            )))?;
5897
5898        // concatenate parent directory and socket file name
5899        socket_parent_dir.push(socket_name);
5900
5901        let command_socket_path = socket_parent_dir
5902            .to_str()
5903            .ok_or(ConfigError::SocketPathError(format!(
5904                "Invalid socket path {socket_parent_dir:?}"
5905            )))?
5906            .to_string();
5907
5908        Ok(command_socket_path)
5909    }
5910
5911    /// Get the path of where the state will be saved
5912    fn saved_state_path(&self) -> Result<Option<String>, ConfigError> {
5913        let path = match self.saved_state.as_ref() {
5914            Some(path) => path,
5915            None => return Ok(None),
5916        };
5917
5918        debug!("saved_stated path in the config: {}", path);
5919        let config_path = PathBuf::from(self.config_path.clone());
5920
5921        debug!("Config path buffer: {:?}", config_path);
5922        let config_dir = config_path
5923            .parent()
5924            .ok_or(ConfigError::SaveStatePath(format!(
5925                "Could get parent directory of config file {config_path:?}"
5926            )))?;
5927
5928        debug!("Config folder: {:?}", config_dir);
5929        if !config_dir.exists() {
5930            create_dir_all(config_dir).map_err(|io_error| {
5931                ConfigError::SaveStatePath(format!(
5932                    "failed to create state parent directory '{config_dir:?}': {io_error}"
5933                ))
5934            })?;
5935        }
5936
5937        let mut saved_state_path_raw = config_dir.to_path_buf();
5938        saved_state_path_raw.push(path);
5939        debug!(
5940            "Looking for saved state on the path {:?}",
5941            saved_state_path_raw
5942        );
5943
5944        match metadata(path) {
5945            Err(err) if matches!(err.kind(), ErrorKind::NotFound) => {
5946                info!("Create an empty state file at '{}'", path);
5947                File::create(path).map_err(|io_error| {
5948                    ConfigError::SaveStatePath(format!(
5949                        "failed to create state file '{path:?}': {io_error}"
5950                    ))
5951                })?;
5952            }
5953            _ => {}
5954        }
5955
5956        saved_state_path_raw.canonicalize().map_err(|io_error| {
5957            ConfigError::SaveStatePath(format!(
5958                "could not get saved state path from config file input {path:?}: {io_error}"
5959            ))
5960        })?;
5961
5962        let stringified_path = saved_state_path_raw
5963            .to_str()
5964            .ok_or(ConfigError::SaveStatePath(format!(
5965                "Invalid path {saved_state_path_raw:?}"
5966            )))?
5967            .to_string();
5968
5969        Ok(Some(stringified_path))
5970    }
5971
5972    /// read any file to a string
5973    pub fn load_file(path: &str) -> Result<String, ConfigError> {
5974        std::fs::read_to_string(path).map_err(|io_error| ConfigError::FileRead {
5975            path_to_read: path.to_owned(),
5976            io_error,
5977        })
5978    }
5979
5980    /// read any file to bytes
5981    pub fn load_file_bytes(path: &str) -> Result<Vec<u8>, ConfigError> {
5982        std::fs::read(path).map_err(|io_error| ConfigError::FileRead {
5983            path_to_read: path.to_owned(),
5984            io_error,
5985        })
5986    }
5987}
5988
5989impl fmt::Debug for Config {
5990    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5991        f.debug_struct("Config")
5992            .field("config_path", &self.config_path)
5993            .field("command_socket", &self.command_socket)
5994            .field(
5995                "command_socket_mode",
5996                &format_args!("{:o}", self.command_socket_mode),
5997            )
5998            .field("command_buffer_size", &self.command_buffer_size)
5999            .field("max_command_buffer_size", &self.max_command_buffer_size)
6000            .field("max_connections", &self.max_connections)
6001            .field("min_buffers", &self.min_buffers)
6002            .field("max_buffers", &self.max_buffers)
6003            .field("buffer_size", &self.buffer_size)
6004            .field("saved_state", &self.saved_state)
6005            .field("automatic_state_save", &self.automatic_state_save)
6006            .field("log_level", &self.log_level)
6007            .field("log_target", &self.log_target)
6008            .field("access_logs_target", &self.access_logs_target)
6009            .field("audit_logs_target", &self.audit_logs_target)
6010            .field("audit_logs_json_target", &self.audit_logs_json_target)
6011            .field("access_logs_format", &self.access_logs_format)
6012            .field("worker_count", &self.worker_count)
6013            .field("worker_automatic_restart", &self.worker_automatic_restart)
6014            .field("metrics", &self.metrics)
6015            .field("disable_cluster_metrics", &self.disable_cluster_metrics)
6016            .field("handle_process_affinity", &self.handle_process_affinity)
6017            .field("ctl_command_timeout", &self.ctl_command_timeout)
6018            .field("pid_file_path", &self.pid_file_path)
6019            .field("activate_listeners", &self.activate_listeners)
6020            .field("front_timeout", &self.front_timeout)
6021            .field("back_timeout", &self.back_timeout)
6022            .field("connect_timeout", &self.connect_timeout)
6023            .field("zombie_check_interval", &self.zombie_check_interval)
6024            .field("accept_queue_timeout", &self.accept_queue_timeout)
6025            .field("evict_on_queue_full", &self.evict_on_queue_full)
6026            .field("request_timeout", &self.request_timeout)
6027            .field("worker_timeout", &self.worker_timeout)
6028            .finish()
6029    }
6030}
6031
6032/// The diagnostic of a configuration file that does not parse: its path and,
6033/// when known, the byte range of the offence.
6034///
6035/// Neither the source nor `toml::de::Error`'s `Display` is used: the latter
6036/// quotes the offending line, and a serde type error quotes the offending
6037/// value, which may be a health-check header credential.
6038fn toml_error_diagnostic(path: &str, error: &toml::de::Error) -> String {
6039    match error.span() {
6040        Some(Range { start, end }) => format!(
6041            "error parsing the configuration file '{path}' at bytes {start}..{end}: invalid TOML syntax or unexpected value (the offending text is not shown)"
6042        ),
6043        None => format!(
6044            "error parsing the configuration file '{path}': invalid TOML syntax or unexpected value (the offending text is not shown)"
6045        ),
6046    }
6047}
6048
6049impl ServerConfig {
6050    /// Default number of slab entries per connection. Set to 4 to accommodate
6051    /// H2 multiplexing (1 frontend + up to 3 backend connections per
6052    /// frontend with stream multiplexing). Previous value was 2 for H1-only
6053    /// operation. Operators with topologies that fan out across more
6054    /// clusters per session can override via `slab_entries_per_connection`
6055    /// in the config (clamped to [2, 32]).
6056    pub const DEFAULT_SLAB_ENTRIES_PER_CONNECTION: u64 = 4;
6057    /// Lower bound for the runtime knob. Below 2 the slab cannot hold one
6058    /// frontend + one backend per session.
6059    pub const MIN_SLAB_ENTRIES_PER_CONNECTION: u64 = 2;
6060    /// Upper bound for the runtime knob. 32 caps memory blow-up from a
6061    /// runaway config; 32 backends per frontend covers any sane topology.
6062    pub const MAX_SLAB_ENTRIES_PER_CONNECTION: u64 = 32;
6063
6064    /// Effective slab-entries-per-connection. Applies the [MIN, MAX] clamp
6065    /// and falls back to the default when the proto field is absent or 0.
6066    pub fn effective_slab_entries_per_connection(&self) -> u64 {
6067        let effective = match self.slab_entries_per_connection {
6068            Some(0) | None => Self::DEFAULT_SLAB_ENTRIES_PER_CONNECTION,
6069            Some(n) => n.clamp(
6070                Self::MIN_SLAB_ENTRIES_PER_CONNECTION,
6071                Self::MAX_SLAB_ENTRIES_PER_CONNECTION,
6072            ),
6073        };
6074        // POST: the effective value is always inside the documented clamp
6075        // window [MIN, MAX], regardless of the raw config input. The default
6076        // itself sits inside that window, so every branch satisfies the bound.
6077        debug_assert!(
6078            (Self::MIN_SLAB_ENTRIES_PER_CONNECTION..=Self::MAX_SLAB_ENTRIES_PER_CONNECTION)
6079                .contains(&effective),
6080            "effective slab entries per connection must stay within [MIN, MAX]"
6081        );
6082        effective
6083    }
6084
6085    /// Size of the slab for the Session manager.
6086    ///
6087    /// With HTTP/2 multiplexing, each frontend session can have multiple backend
6088    /// connections (one per cluster), so we allocate
6089    /// [`Self::effective_slab_entries_per_connection`] entries per connection
6090    /// instead of the old H1-only multiplier of 2.
6091    pub fn slab_capacity(&self) -> u64 {
6092        let per_conn = self.effective_slab_entries_per_connection();
6093        let capacity = 10 + per_conn * self.max_connections;
6094        // POST: the slab always reserves the 10-entry base (listeners, command
6095        // channel, etc.) plus at least MIN entries per connection, so it can
6096        // never be smaller than the base. Strict `>` for any non-zero
6097        // max_connections since per_conn >= MIN >= 2.
6098        debug_assert!(
6099            capacity >= 10,
6100            "slab capacity must reserve the base entries"
6101        );
6102        debug_assert!(
6103            self.max_connections == 0 || capacity > 10,
6104            "a non-zero connection cap must reserve per-connection slab entries"
6105        );
6106        capacity
6107    }
6108}
6109
6110/// reduce the config to the bare minimum needed by a worker
6111impl From<&Config> for ServerConfig {
6112    fn from(config: &Config) -> Self {
6113        let metrics = config.metrics.clone().map(|m| ServerMetricsConfig {
6114            address: m.address.to_string(),
6115            tagged_metrics: m.tagged_metrics,
6116            prefix: m.prefix,
6117            detail: Some(MetricDetail::from(m.detail) as i32),
6118        });
6119        let server_config = Self {
6120            max_connections: config.max_connections as u64,
6121            front_timeout: config.front_timeout,
6122            back_timeout: config.back_timeout,
6123            connect_timeout: config.connect_timeout,
6124            zombie_check_interval: config.zombie_check_interval,
6125            accept_queue_timeout: config.accept_queue_timeout,
6126            min_buffers: config.min_buffers,
6127            max_buffers: config.max_buffers,
6128            buffer_size: config.buffer_size,
6129            log_level: config.log_level.clone(),
6130            log_target: config.log_target.clone(),
6131            access_logs_target: config.access_logs_target.clone(),
6132            audit_logs_target: config.audit_logs_target.clone(),
6133            audit_logs_json_target: config.audit_logs_json_target.clone(),
6134            command_buffer_size: config.command_buffer_size,
6135            max_command_buffer_size: config.max_command_buffer_size,
6136            metrics,
6137            access_log_format: ProtobufAccessLogFormat::from(&config.access_logs_format) as i32,
6138            log_colored: config.log_colored,
6139            slab_entries_per_connection: config.slab_entries_per_connection,
6140            basic_auth_max_credential_bytes: config.basic_auth_max_credential_bytes,
6141            evict_on_queue_full: Some(config.evict_on_queue_full),
6142            max_connections_per_ip: Some(config.max_connections_per_ip),
6143            max_connections_per_subnet: Some(config.max_connections_per_subnet),
6144            subnet_ipv4_prefix: Some(config.subnet_ipv4_prefix),
6145            subnet_ipv6_prefix: Some(config.subnet_ipv6_prefix),
6146            retry_after: Some(config.retry_after),
6147            splice_pipe_capacity_bytes: config.splice_pipe_capacity_bytes,
6148            max_connection_attempts: Some(config.max_connection_attempts),
6149        };
6150
6151        // POST: the worker-facing config preserves the buffer-pool invariant
6152        // (min <= max) and carries the sizing knobs through unchanged — a
6153        // worker derives its slab and buffer pool straight from these, so any
6154        // drift here would desynchronize the master's view from the worker's.
6155        debug_assert!(
6156            server_config.min_buffers <= server_config.max_buffers,
6157            "ServerConfig must preserve min_buffers <= max_buffers"
6158        );
6159        debug_assert_eq!(
6160            server_config.buffer_size, config.buffer_size,
6161            "ServerConfig buffer_size must mirror the source config"
6162        );
6163        debug_assert_eq!(
6164            server_config.max_connections, config.max_connections as u64,
6165            "ServerConfig max_connections must mirror the source config"
6166        );
6167        server_config
6168    }
6169}
6170
6171#[cfg(test)]
6172mod tests {
6173    use toml::to_string;
6174
6175    use super::*;
6176
6177    /// `redirect = "silent_close"` parses, in any case, to
6178    /// `RedirectPolicy::SilentClose` (sozu-proxy/sozu#767); the hyphenated
6179    /// spelling is not an alias and is refused like any unknown policy.
6180    #[test]
6181    fn silent_close_redirect_policy_parses_case_insensitively() {
6182        for spelling in ["silent_close", "SILENT_CLOSE", "Silent_Close"] {
6183            assert_eq!(
6184                parse_redirect_policy(spelling).ok(),
6185                Some(RedirectPolicy::SilentClose),
6186                "{spelling} must parse to SilentClose"
6187            );
6188        }
6189        assert!(matches!(
6190            parse_redirect_policy("silent-close"),
6191            Err(ConfigError::InvalidRedirectPolicy(value)) if value == "silent-close"
6192        ));
6193    }
6194
6195    #[test]
6196    fn http_frontend_debug_redacts_pem_material() {
6197        const CERTIFICATE_SECRET: &str = "HTTP_FRONTEND_CERTIFICATE_PEM_SECRET_SENTINEL";
6198        const CHAIN_SECRET: &str = "HTTP_FRONTEND_CHAIN_PEM_SECRET_SENTINEL";
6199        const KEY_SECRET: &str = "HTTP_FRONTEND_KEY_PEM_SECRET_SENTINEL";
6200        const HOSTNAME_SECRET: &str = "HTTP_FRONTEND_HOSTNAME_SECRET_SENTINEL";
6201        const PATH_SECRET: &str = "HTTP_FRONTEND_PATH_SECRET_SENTINEL";
6202        const METHOD_SECRET: &str = "HTTP_FRONTEND_METHOD_SECRET_SENTINEL";
6203        const TAG_KEY_SECRET: &str = "HTTP_FRONTEND_TAG_KEY_SECRET_SENTINEL";
6204        const TAG_SECRET: &str = "HTTP_FRONTEND_TAG_VALUE_SECRET_SENTINEL";
6205        const HEADER_KEY_SECRET: &str = "HTTP_FRONTEND_HEADER_KEY_SECRET_SENTINEL";
6206        const HEADER_SECRET: &str = "HTTP_FRONTEND_HEADER_VALUE_SECRET_SENTINEL";
6207        const REDIRECT_TEMPLATE_SECRET: &str = "HTTP_FRONTEND_REDIRECT_TEMPLATE_SECRET_SENTINEL";
6208        const REWRITE_HOST_SECRET: &str = "HTTP_FRONTEND_REWRITE_HOST_SECRET_SENTINEL";
6209        const REWRITE_PATH_SECRET: &str = "HTTP_FRONTEND_REWRITE_PATH_SECRET_SENTINEL";
6210
6211        let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
6212        let certificate = long_value(CERTIFICATE_SECRET);
6213        let certificate_chain = long_value(CHAIN_SECRET);
6214        let key = long_value(KEY_SECRET);
6215        let hostname = long_value(HOSTNAME_SECRET);
6216        let path = long_value(PATH_SECRET);
6217        let method = long_value(METHOD_SECRET);
6218        let redirect_template = long_value(REDIRECT_TEMPLATE_SECRET);
6219        let rewrite_host = long_value(REWRITE_HOST_SECRET);
6220        let rewrite_path = long_value(REWRITE_PATH_SECRET);
6221
6222        let frontend = HttpFrontendConfig {
6223            address: "127.0.0.1:8443".parse().unwrap(),
6224            hostname,
6225            path: PathRule::prefix(path),
6226            method: Some(method),
6227            certificate: Some(certificate),
6228            key: Some(key),
6229            certificate_chain: Some(vec![certificate_chain]),
6230            tls_versions: vec![TlsVersion::TlsV13],
6231            position: RulePosition::Tree,
6232            tags: Some(BTreeMap::from([(
6233                long_value(TAG_KEY_SECRET),
6234                long_value(TAG_SECRET),
6235            )])),
6236            redirect: None,
6237            redirect_scheme: None,
6238            redirect_template: Some(redirect_template),
6239            rewrite_host: Some(rewrite_host),
6240            rewrite_path: Some(rewrite_path),
6241            rewrite_port: None,
6242            required_auth: None,
6243            headers: vec![Header {
6244                position: HeaderPosition::Request as i32,
6245                key: long_value(HEADER_KEY_SECRET),
6246                val: long_value(HEADER_SECRET),
6247            }],
6248            hsts: None,
6249            header_predicates: Vec::new(),
6250        };
6251
6252        let output = format!("{frontend:?}");
6253
6254        let secrets = [
6255            CERTIFICATE_SECRET,
6256            CHAIN_SECRET,
6257            KEY_SECRET,
6258            HOSTNAME_SECRET,
6259            PATH_SECRET,
6260            METHOD_SECRET,
6261            TAG_KEY_SECRET,
6262            TAG_SECRET,
6263            HEADER_KEY_SECRET,
6264            HEADER_SECRET,
6265            REDIRECT_TEMPLATE_SECRET,
6266            REWRITE_HOST_SECRET,
6267            REWRITE_PATH_SECRET,
6268        ];
6269        for secret in secrets {
6270            assert!(
6271                !output.contains(secret),
6272                "HttpFrontendConfig Debug leaked secret marker {secret}: {output}"
6273            );
6274        }
6275        let expected_metadata = [
6276            "address: 127.0.0.1:8443".to_owned(),
6277            format!("hostname_len: {}", long_value(HOSTNAME_SECRET).len()),
6278            format!("path_kind: {}", PathRule::prefix(String::new()).kind),
6279            format!("path_len: {}", long_value(PATH_SECRET).len()),
6280            format!("method_len: Some({})", long_value(METHOD_SECRET).len()),
6281            "certificate: Some(\"[redacted]\")".to_owned(),
6282            format!(
6283                "certificate_len: Some({})",
6284                long_value(CERTIFICATE_SECRET).len()
6285            ),
6286            "key: Some(\"[redacted]\")".to_owned(),
6287            format!("key_len: Some({})", long_value(KEY_SECRET).len()),
6288            "certificate_chain: Some(\"[redacted]\")".to_owned(),
6289            "certificate_chain_count: Some(1)".to_owned(),
6290            format!(
6291                "certificate_chain_len: Some({})",
6292                long_value(CHAIN_SECRET).len()
6293            ),
6294            "tls_versions_count: 1".to_owned(),
6295            "tags_count: 1".to_owned(),
6296            format!(
6297                "redirect_template_len: Some({})",
6298                long_value(REDIRECT_TEMPLATE_SECRET).len()
6299            ),
6300            format!(
6301                "rewrite_host_len: Some({})",
6302                long_value(REWRITE_HOST_SECRET).len()
6303            ),
6304            format!(
6305                "rewrite_path_len: Some({})",
6306                long_value(REWRITE_PATH_SECRET).len()
6307            ),
6308            "headers_count: 1".to_owned(),
6309        ];
6310        for safe_metadata in expected_metadata {
6311            assert!(
6312                output.contains(&safe_metadata),
6313                "HttpFrontendConfig Debug omitted safe metadata {safe_metadata}: {output}"
6314            );
6315        }
6316        assert!(
6317            output.len() <= 1024,
6318            "HttpFrontendConfig Debug output is not bounded: {} bytes",
6319            output.len()
6320        );
6321
6322        for (index, request) in frontend
6323            .generate_requests("safe-cluster-id")
6324            .into_iter()
6325            .enumerate()
6326        {
6327            let generated_output = format!("{request:?}");
6328            for secret in secrets {
6329                assert!(
6330                    !generated_output.contains(secret),
6331                    "generated frontend request {index} Debug leaked secret marker {secret}: {generated_output}"
6332                );
6333            }
6334            assert!(
6335                generated_output.len() <= 2048,
6336                "generated frontend request {index} Debug output is not bounded: {} bytes",
6337                generated_output.len()
6338            );
6339        }
6340    }
6341
6342    #[test]
6343    fn hsts_to_proto_enabled_substitutes_default_max_age() {
6344        let cfg = FileHstsConfig {
6345            enabled: Some(true),
6346            max_age: None,
6347            include_subdomains: None,
6348            preload: None,
6349            force_replace_backend: None,
6350        };
6351        let proto = cfg.to_proto("test").expect("should validate");
6352        assert_eq!(proto.enabled, Some(true));
6353        assert_eq!(proto.max_age, Some(DEFAULT_HSTS_MAX_AGE));
6354    }
6355
6356    #[test]
6357    fn hsts_to_proto_explicit_max_age_kept() {
6358        let cfg = FileHstsConfig {
6359            enabled: Some(true),
6360            max_age: Some(63_072_000),
6361            include_subdomains: Some(true),
6362            preload: Some(true),
6363            force_replace_backend: None,
6364        };
6365        let proto = cfg.to_proto("test").expect("should validate");
6366        assert_eq!(proto.max_age, Some(63_072_000));
6367        assert_eq!(proto.include_subdomains, Some(true));
6368        assert_eq!(proto.preload, Some(true));
6369    }
6370
6371    #[test]
6372    fn hsts_to_proto_disabled_keeps_zero_intent() {
6373        // `enabled = false` means "explicit disable" — the materialiser
6374        // in `Frontend::new` won't append an edit, so the proto still
6375        // round-trips with `enabled = Some(false)`.
6376        let cfg = FileHstsConfig {
6377            enabled: Some(false),
6378            max_age: None,
6379            include_subdomains: None,
6380            preload: None,
6381            force_replace_backend: None,
6382        };
6383        let proto = cfg.to_proto("test").expect("should validate");
6384        assert_eq!(proto.enabled, Some(false));
6385    }
6386
6387    #[test]
6388    fn hsts_to_proto_kill_switch_max_age_zero_allowed() {
6389        // RFC 6797 §11.4: `max-age=0` instructs the UA to "cease
6390        // regarding the host as a Known HSTS Host". Explicit operator
6391        // intent — must NOT warn or fail.
6392        let cfg = FileHstsConfig {
6393            enabled: Some(true),
6394            max_age: Some(0),
6395            include_subdomains: None,
6396            preload: None,
6397            force_replace_backend: None,
6398        };
6399        let proto = cfg.to_proto("test").expect("kill-switch must validate");
6400        assert_eq!(proto.max_age, Some(0));
6401    }
6402
6403    #[test]
6404    fn hsts_to_proto_missing_enabled_errors() {
6405        let cfg = FileHstsConfig {
6406            enabled: None,
6407            max_age: Some(31_536_000),
6408            include_subdomains: None,
6409            preload: None,
6410            force_replace_backend: None,
6411        };
6412        match cfg.to_proto("test").unwrap_err() {
6413            ConfigError::HstsEnabledRequired(scope) => assert_eq!(scope, "test"),
6414            other => panic!("expected HstsEnabledRequired, got {other:?}"),
6415        }
6416    }
6417
6418    #[test]
6419    fn hsts_rejected_on_http_listener() {
6420        // RFC 6797 §7.2: an [hsts] block on an HTTP listener must be
6421        // rejected at TOML config-load — `HttpListenerConfig` carries no
6422        // `hsts` field and silently dropping the operator's intent
6423        // would be a worse failure mode than a typed error.
6424        let mut listener = ListenerBuilder::new(
6425            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6426            ListenerProtocol::Http,
6427        );
6428        listener.hsts = Some(FileHstsConfig {
6429            enabled: Some(true),
6430            max_age: Some(31_536_000),
6431            include_subdomains: None,
6432            preload: None,
6433            force_replace_backend: None,
6434        });
6435        match listener.to_http(None).unwrap_err() {
6436            ConfigError::HstsOnPlainHttp(scope) => assert!(
6437                scope.contains("HTTP listener"),
6438                "expected scope to mention 'HTTP listener', got {scope:?}"
6439            ),
6440            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
6441        }
6442    }
6443
6444    /// Regression for sozu-proxy/sozu#1418: an H2 flood threshold of `0` set
6445    /// in the config file reached `HttpListenerConfig` untouched. From there
6446    /// `lib/src/http.rs::get_h2_flood_config` handed it to
6447    /// `H2FloodDetector::new`, whose `check_flood` compares `count >
6448    /// threshold` — so the first counted RST_STREAM of every H2 connection
6449    /// tripped the detector and the listener answered `ENHANCE_YOUR_CALM` to
6450    /// every client. `UpdateHttpListener` already refused the same value
6451    /// (`validate_h2_flood_knobs_http`); the config-file path did not.
6452    #[test]
6453    fn h2_flood_threshold_zero_rejected_on_http_listener() {
6454        let mut listener = ListenerBuilder::new(
6455            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6456            ListenerProtocol::Http,
6457        );
6458        listener.h2_max_rst_stream_per_window = Some(0);
6459        match listener.to_http(None).unwrap_err() {
6460            ConfigError::H2ThresholdBelowMinimum {
6461                key,
6462                value,
6463                minimum,
6464                ..
6465            } => {
6466                assert_eq!(key, "h2_max_rst_stream_per_window");
6467                assert_eq!(value, 0);
6468                assert_eq!(minimum, 1);
6469            }
6470            other => panic!("expected H2ThresholdBelowMinimum, got {other:?}"),
6471        }
6472    }
6473
6474    /// The HTTPS listener path (`to_tls`) carries the same knobs and needs the
6475    /// same gate (sozu-proxy/sozu#1418). A lifetime cap is used here rather
6476    /// than a per-window one so the `u64` knobs are covered too.
6477    #[test]
6478    fn h2_flood_threshold_zero_rejected_on_https_listener() {
6479        let mut listener = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
6480        listener.h2_max_rst_stream_abusive_lifetime = Some(0);
6481        match listener.to_tls(None).unwrap_err() {
6482            ConfigError::H2ThresholdBelowMinimum {
6483                key,
6484                value,
6485                minimum,
6486                ..
6487            } => {
6488                assert_eq!(key, "h2_max_rst_stream_abusive_lifetime");
6489                assert_eq!(value, 0);
6490                assert_eq!(minimum, 1);
6491            }
6492            other => panic!("expected H2ThresholdBelowMinimum, got {other:?}"),
6493        }
6494    }
6495
6496    /// `h2_stream_shrink_ratio` has a runtime floor of 2, not 1
6497    /// (`lib/src/protocol/mux/h2.rs`'s `.max(2)`), and
6498    /// `validate_h2_flood_knobs_http` rejects `< 2` on the update path. The
6499    /// config-file path must agree (sozu-proxy/sozu#1418).
6500    #[test]
6501    fn h2_stream_shrink_ratio_below_two_rejected_on_http_listener() {
6502        let mut listener = ListenerBuilder::new(
6503            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6504            ListenerProtocol::Http,
6505        );
6506        listener.h2_stream_shrink_ratio = Some(1);
6507        match listener.to_http(None).unwrap_err() {
6508            ConfigError::H2ThresholdBelowMinimum {
6509                key,
6510                value,
6511                minimum,
6512                ..
6513            } => {
6514                assert_eq!(key, "h2_stream_shrink_ratio");
6515                assert_eq!(value, 1);
6516                assert_eq!(minimum, 2);
6517            }
6518            other => panic!("expected H2ThresholdBelowMinimum, got {other:?}"),
6519        }
6520    }
6521
6522    /// The boundary itself must load: every documented minimum is an accepted
6523    /// value, not a rejected one.
6524    #[test]
6525    fn h2_thresholds_at_their_minimum_accepted() {
6526        let mut listener = ListenerBuilder::new(
6527            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6528            ListenerProtocol::Http,
6529        );
6530        listener.h2_max_rst_stream_per_window = Some(1);
6531        listener.h2_max_rst_stream_abusive_lifetime = Some(1);
6532        listener.h2_stream_shrink_ratio = Some(2);
6533        let config = listener
6534            .to_http(None)
6535            .expect("thresholds at their minimum must be accepted");
6536        assert_eq!(config.h2_max_rst_stream_per_window, Some(1));
6537        assert_eq!(config.h2_max_rst_stream_abusive_lifetime, Some(1));
6538        assert_eq!(config.h2_stream_shrink_ratio, Some(2));
6539    }
6540
6541    #[test]
6542    fn hsts_rejected_on_http_frontend() {
6543        // A `FileClusterFrontendConfig` without a key+certificate pair
6544        // generates `RequestType::AddHttpFrontend` in
6545        // `HttpFrontendConfig::generate_requests`. RFC 6797 §7.2 forbids
6546        // HSTS on plaintext HTTP, so an `[hsts]` block on a cert-less
6547        // (HTTP-bound) frontend must be rejected at TOML config-load.
6548        let frontend = FileClusterFrontendConfig {
6549            address: "127.0.0.1:8080".parse().unwrap(),
6550            hostname: Some("example.com".to_owned()),
6551            alpn: vec![],
6552            path: None,
6553            path_type: None,
6554            method: None,
6555            certificate: None,
6556            key: None,
6557            certificate_chain: None,
6558            tls_versions: vec![],
6559            position: RulePosition::Tree,
6560            tags: None,
6561            redirect: None,
6562            redirect_scheme: None,
6563            redirect_template: None,
6564            rewrite_host: None,
6565            rewrite_path: None,
6566            rewrite_port: None,
6567            required_auth: None,
6568            headers: None,
6569            hsts: Some(FileHstsConfig {
6570                enabled: Some(true),
6571                max_age: Some(31_536_000),
6572                include_subdomains: None,
6573                preload: None,
6574                force_replace_backend: None,
6575            }),
6576            header_predicates: None,
6577        };
6578        match frontend.to_http_front("api").unwrap_err() {
6579            ConfigError::HstsOnPlainHttp(scope) => {
6580                assert!(
6581                    scope.contains("api") && scope.contains("example.com"),
6582                    "expected scope to mention 'api' and 'example.com', got {scope:?}"
6583                );
6584            }
6585            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
6586        }
6587    }
6588
6589    #[test]
6590    fn serialize() {
6591        let http = ListenerBuilder::new(
6592            SocketAddress::new_v4(127, 0, 0, 1, 8080),
6593            ListenerProtocol::Http,
6594        )
6595        .with_answer_404_path(Some("404.html"))
6596        .to_owned();
6597        println!("http: {:?}", to_string(&http));
6598
6599        let https = ListenerBuilder::new(
6600            SocketAddress::new_v4(127, 0, 0, 1, 8443),
6601            ListenerProtocol::Https,
6602        )
6603        .with_answer_404_path(Some("404.html"))
6604        .to_owned();
6605        println!("https: {:?}", to_string(&https));
6606
6607        let listeners = vec![http, https];
6608        let config = FileConfig {
6609            command_socket: Some(String::from("./command_folder/sock")),
6610            worker_count: Some(2),
6611            worker_automatic_restart: Some(true),
6612            max_connections: Some(500),
6613            min_buffers: Some(1),
6614            max_buffers: Some(500),
6615            buffer_size: Some(16393),
6616            metrics: Some(MetricsConfig {
6617                address: "127.0.0.1:8125".parse().unwrap(),
6618                tagged_metrics: false,
6619                prefix: Some(String::from("sozu-metrics")),
6620                detail: MetricDetailLevel::default(),
6621            }),
6622            listeners: Some(listeners),
6623            ..Default::default()
6624        };
6625
6626        println!("config: {:?}", to_string(&config));
6627        let encoded = to_string(&config).unwrap();
6628        println!("conf:\n{encoded}");
6629    }
6630
6631    #[test]
6632    fn parse() {
6633        let path = "assets/config.toml";
6634        let config = Config::load_from_path(path).unwrap_or_else(|load_error| {
6635            panic!("Cannot load config from path {path}: {load_error:?}")
6636        });
6637        println!("config: {config:#?}");
6638        //panic!();
6639    }
6640
6641    #[test]
6642    fn multiple_listeners_preserve_per_address_expect_proxy() {
6643        let toml_content = r#"
6644            command_socket = "/tmp/sozu_test.sock"
6645            worker_count = 1
6646
6647            [[listeners]]
6648            protocol = "http"
6649            address = "172.16.20.1:80"
6650            expect_proxy = true
6651
6652            [[listeners]]
6653            protocol = "http"
6654            address = "10.22.0.1:80"
6655            expect_proxy = false
6656
6657            [[listeners]]
6658            protocol = "https"
6659            address = "192.168.1.1:443"
6660            expect_proxy = true
6661
6662            [[listeners]]
6663            protocol = "https"
6664            address = "192.168.2.1:443"
6665            expect_proxy = false
6666        "#;
6667
6668        let file_config: FileConfig =
6669            toml::from_str(toml_content).expect("Could not parse TOML config");
6670
6671        let listeners = file_config.listeners.as_ref().expect("No listeners found");
6672        assert_eq!(listeners.len(), 4);
6673
6674        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
6675            .into_config()
6676            .expect("Could not build config");
6677
6678        assert_eq!(config.http_listeners.len(), 2);
6679        assert_eq!(config.https_listeners.len(), 2);
6680
6681        // HTTP listeners
6682        let http_proxy = config
6683            .http_listeners
6684            .iter()
6685            .find(|l| SocketAddr::from(l.address) == "172.16.20.1:80".parse().unwrap())
6686            .expect("Listener on 172.16.20.1:80 not found");
6687        let http_direct = config
6688            .http_listeners
6689            .iter()
6690            .find(|l| SocketAddr::from(l.address) == "10.22.0.1:80".parse().unwrap())
6691            .expect("Listener on 10.22.0.1:80 not found");
6692
6693        assert!(http_proxy.expect_proxy);
6694        assert!(!http_direct.expect_proxy);
6695
6696        // HTTPS listeners
6697        let https_proxy = config
6698            .https_listeners
6699            .iter()
6700            .find(|l| SocketAddr::from(l.address) == "192.168.1.1:443".parse().unwrap())
6701            .expect("Listener on 192.168.1.1:443 not found");
6702        let https_direct = config
6703            .https_listeners
6704            .iter()
6705            .find(|l| SocketAddr::from(l.address) == "192.168.2.1:443".parse().unwrap())
6706            .expect("Listener on 192.168.2.1:443 not found");
6707
6708        assert!(https_proxy.expect_proxy);
6709        assert!(!https_direct.expect_proxy);
6710    }
6711
6712    /// #347: builds the HTTP cluster `cluster` from a TOML backend list.
6713    fn http_cluster_from_backends(backends: &str) -> Result<HttpClusterConfig, ConfigError> {
6714        let file: FileClusterConfig = toml::from_str(&format!(
6715            "protocol = \"http\"\nfrontends = []\nbackends = [{backends}]\n"
6716        ))
6717        .expect("the cluster must parse");
6718        match file.to_cluster_config("cluster", &HashSet::new())? {
6719            ClusterConfig::Http(http) => Ok(http),
6720            ClusterConfig::Tcp(_) => panic!("an http cluster must build as one"),
6721        }
6722    }
6723
6724    /// #347: a `host:port` backend address is resolved when the file is
6725    /// loaded, into loopback backends with ids derived from the entry and
6726    /// the address, and the same file always yields the same backends.
6727    #[test]
6728    fn a_backend_hostname_resolves_when_the_file_is_loaded() {
6729        let backends = r#"{ address = "localhost:8080", weight = 50 }"#;
6730        let http = http_cluster_from_backends(backends).expect("localhost must resolve");
6731
6732        assert!(
6733            !http.backends.is_empty(),
6734            "localhost resolves to an address"
6735        );
6736        let mut ids = HashSet::new();
6737        for backend in &http.backends {
6738            assert!(backend.address.ip().is_loopback(), "{backend:?}");
6739            assert_eq!(backend.address.port(), 8080);
6740            assert_eq!(backend.weight, Some(50));
6741            assert_eq!(
6742                backend.backend_id.as_deref(),
6743                Some(format!("cluster-localhost:8080-{}", backend.address).as_str())
6744            );
6745            assert!(ids.insert(backend.backend_id.clone()), "ids are distinct");
6746        }
6747        assert_eq!(
6748            http_cluster_from_backends(backends)
6749                .expect("localhost must resolve")
6750                .backends,
6751            http.backends,
6752            "the same file yields the same backends"
6753        );
6754
6755        let orders = http.generate_requests().expect("the orders must build");
6756        let added: Vec<&AddBackend> = orders
6757            .iter()
6758            .filter_map(|order| match order.request_type.as_ref() {
6759                Some(RequestType::AddBackend(add)) => Some(add),
6760                _ => None,
6761            })
6762            .collect();
6763        assert_eq!(added.len(), http.backends.len());
6764        for (add, backend) in added.iter().zip(&http.backends) {
6765            assert_eq!(Some(&add.backend_id), backend.backend_id.as_ref());
6766            assert_eq!(SocketAddr::from(add.address), backend.address);
6767        }
6768    }
6769
6770    /// #347: a socket-address literal never reaches the resolver and keeps
6771    /// the positional default id, so existing files load unchanged.
6772    #[test]
6773    fn a_literal_backend_address_keeps_its_positional_id() {
6774        let http = http_cluster_from_backends(
6775            r#"{ address = "127.0.0.1:8080" }, { address = "[::1]:8081", backend_id = "six" }"#,
6776        )
6777        .expect("literals must build");
6778        assert_eq!(
6779            http.backends,
6780            vec![
6781                BackendConfig {
6782                    address: "127.0.0.1:8080".parse().unwrap(),
6783                    weight: None,
6784                    sticky_id: None,
6785                    backup: None,
6786                    backend_id: Some("cluster-0-127.0.0.1:8080".to_owned()),
6787                    unix_path: None,
6788                },
6789                BackendConfig {
6790                    address: "[::1]:8081".parse().unwrap(),
6791                    weight: None,
6792                    sticky_id: None,
6793                    backup: None,
6794                    backend_id: Some("six".to_owned()),
6795                    unix_path: None,
6796                },
6797            ]
6798        );
6799    }
6800
6801    /// #347: a literal keeps the id of its position in the file, whatever
6802    /// the number of addresses a name before it resolved to, on the HTTP and
6803    /// the TCP path alike. The resolver is injected, so the answer size does
6804    /// not depend on the host.
6805    #[test]
6806    fn a_literal_after_a_name_keeps_its_file_position_id() {
6807        let one: SocketAddr = "10.0.0.2:8080".parse().unwrap();
6808        let two: SocketAddr = "10.0.0.3:8080".parse().unwrap();
6809        let answer = |addresses: Vec<SocketAddr>| {
6810            move |_: &str| -> std::io::Result<Vec<SocketAddr>> { Ok(addresses.clone()) }
6811        };
6812
6813        for (protocol, addresses) in [
6814            ("http", vec![one]),
6815            ("http", vec![one, two]),
6816            ("tcp", vec![one]),
6817            ("tcp", vec![one, two]),
6818        ] {
6819            let resolver = answer(addresses.clone());
6820            let file: FileClusterConfig = toml::from_str(&format!(
6821                "protocol = \"{protocol}\"\nfrontends = []\n\
6822                 backends = [{{ address = \"name:8080\" }}, {{ address = \"127.0.0.1:9000\" }}]\n"
6823            ))
6824            .expect("the cluster must parse");
6825            let backends = match file
6826                .to_cluster_config_with("cluster", &HashSet::new(), Some(&resolver))
6827                .expect("the cluster must build")
6828            {
6829                ClusterConfig::Http(http) => http.backends,
6830                ClusterConfig::Tcp(tcp) => tcp.backends,
6831            };
6832            assert_eq!(backends.len(), addresses.len() + 1, "{protocol}");
6833            assert_eq!(
6834                backends.last().and_then(|b| b.backend_id.as_deref()),
6835                Some("cluster-1-127.0.0.1:9000"),
6836                "{protocol} with {} resolved address(es)",
6837                addresses.len()
6838            );
6839        }
6840
6841        let error = toml::from_str::<FileClusterConfig>(
6842            "protocol = \"tcp\"\nfrontends = []\nbackends = [{ address = \"localhost\" }]\n",
6843        )
6844        .expect("the cluster must parse")
6845        .to_cluster_config("cluster", &HashSet::new())
6846        .expect_err("a missing port refuses the tcp cluster");
6847        assert!(matches!(
6848            error,
6849            ConfigError::BackendAddressResolution { .. }
6850        ));
6851    }
6852
6853    /// #347: digit-and-dot hosts that are not an IP literal are refused
6854    /// before the resolver, which would read them as legacy numeric
6855    /// shorthand (`010.0.0.1` as `8.0.0.1`, `1.2.3` as `1.2.0.3`).
6856    #[test]
6857    fn numeric_shorthand_backend_hosts_never_reach_the_resolver() {
6858        let shorthand = |_: &str| -> std::io::Result<Vec<SocketAddr>> {
6859            Ok(vec!["8.0.0.1:8080".parse().unwrap()])
6860        };
6861        for address in [
6862            "010.0.0.1:8080",
6863            "1.2.3:8080",
6864            "2130706433:8080",
6865            "0x7f000001:8080",
6866            "0x7f.1:8080",
6867            "0X7F.0.0.1:8080",
6868            "0177.1:8080",
6869            "0x7f.0177.1:8080",
6870        ] {
6871            let entry = FileBackendConfig {
6872                address: address.to_owned(),
6873                weight: None,
6874                sticky_id: None,
6875                backup: None,
6876                backend_id: None,
6877                unix_path: None,
6878            };
6879            let error = entry
6880                .resolve("cluster", 0, &shorthand)
6881                .expect_err("numeric shorthand must be refused");
6882            let ConfigError::BackendAddressResolution {
6883                cluster_id,
6884                address: entry_address,
6885                ..
6886            } = &error
6887            else {
6888                panic!("{address}: unexpected error {error:?}");
6889            };
6890            assert_eq!(cluster_id, "cluster");
6891            assert_eq!(entry_address, address);
6892        }
6893    }
6894
6895    /// #347: an IPv6 literal must be bracketed (`[addr]:port`); the
6896    /// unbracketed forms are refused before the resolver, which splits on
6897    /// the last colon and would accept them numerically. The bracketed form
6898    /// still parses directly.
6899    #[test]
6900    fn unbracketed_ipv6_backend_hosts_never_reach_the_resolver() {
6901        let unreachable = |address: &str| -> std::io::Result<Vec<SocketAddr>> {
6902            panic!("{address}: must not reach the resolver")
6903        };
6904        for address in ["::1:8080", "2001:db8::1:8080", "fe80::1%eth0:80"] {
6905            let entry = FileBackendConfig {
6906                address: address.to_owned(),
6907                weight: None,
6908                sticky_id: None,
6909                backup: None,
6910                backend_id: None,
6911                unix_path: None,
6912            };
6913            let error = entry
6914                .resolve("cluster", 0, &unreachable)
6915                .expect_err("an unbracketed IPv6 literal must be refused");
6916            let ConfigError::BackendAddressResolution {
6917                cluster_id,
6918                address: entry_address,
6919                reason,
6920            } = &error
6921            else {
6922                panic!("{address}: unexpected error {error:?}");
6923            };
6924            assert_eq!(cluster_id, "cluster");
6925            assert_eq!(entry_address, address);
6926            assert!(reason.contains("[address]:port"), "{address}: {reason}");
6927        }
6928
6929        let entry = FileBackendConfig {
6930            address: "[2001:db8::1]:8080".to_owned(),
6931            weight: Some(3),
6932            sticky_id: Some("sticky".to_owned()),
6933            backup: Some(true),
6934            backend_id: None,
6935            unix_path: None,
6936        };
6937        let backends = entry
6938            .resolve("cluster", 2, &unreachable)
6939            .expect("a bracketed IPv6 literal parses directly");
6940        let address: SocketAddr = "[2001:db8::1]:8080".parse().unwrap();
6941        assert_eq!(backends.len(), 1);
6942        assert_eq!(backends[0].address, address);
6943        assert_eq!(backends[0].weight, Some(3));
6944        assert_eq!(backends[0].sticky_id.as_deref(), Some("sticky"));
6945        assert_eq!(backends[0].backup, Some(true));
6946        assert_eq!(
6947            backends[0].backend_id.as_deref(),
6948            Some("cluster-2-[2001:db8::1]:8080")
6949        );
6950    }
6951
6952    /// #347: names that merely contain digits or hex-looking labels still
6953    /// reach the resolver.
6954    #[test]
6955    fn benign_backend_hostnames_are_not_numeric_shorthand() {
6956        let resolved = "10.0.0.2:8080".parse::<SocketAddr>().unwrap();
6957        let resolver = |_: &str| -> std::io::Result<Vec<SocketAddr>> { Ok(vec![resolved]) };
6958        for address in [
6959            "123.example.com:8080",
6960            "dead.beef:8080",
6961            "0x.example.com:8080",
6962            "1.2.3.example:8080",
6963            "localhost:8080",
6964        ] {
6965            let entry = FileBackendConfig {
6966                address: address.to_owned(),
6967                weight: None,
6968                sticky_id: None,
6969                backup: None,
6970                backend_id: None,
6971                unix_path: None,
6972            };
6973            let backends = entry
6974                .resolve("cluster", 0, &resolver)
6975                .unwrap_or_else(|error| panic!("{address}: {error:?}"));
6976            assert_eq!(backends.len(), 1, "{address}");
6977        }
6978        assert!(!is_legacy_numeric_host("dead.beef"));
6979        assert!(!is_legacy_numeric_host("1..2"));
6980        assert!(is_legacy_numeric_host("0x7f.1"));
6981    }
6982
6983    /// #347: the control commands load the file without resolving backend
6984    /// names; the startup and `config check` load still refuses it. `1.2.3`
6985    /// is refused before any DNS query, so the test needs no network.
6986    #[test]
6987    fn a_control_load_does_not_resolve_backend_names() {
6988        let dir = tempfile::tempdir().expect("tempdir");
6989        let path = dir.path().join("config.toml");
6990        std::fs::write(
6991            &path,
6992            "command_socket = \"/tmp/sozu_test.sock\"\n\
6993             ctl_command_timeout = 4242\n\
6994             [clusters.app]\n\
6995             protocol = \"http\"\n\
6996             frontends = [{ address = \"127.0.0.1:8080\", hostname = \"example.org\" }]\n\
6997             backends = [{ address = \"1.2.3:8080\" }, { address = \"127.0.0.1:9000\" }]\n",
6998        )
6999        .expect("write config");
7000        let path = path.to_str().expect("utf-8 path");
7001
7002        assert!(matches!(
7003            Config::load_from_path(path),
7004            Err(ConfigError::BackendAddressResolution { .. })
7005        ));
7006
7007        let config = Config::load_from_path_for_control(path).expect("control load");
7008        assert_eq!(config.ctl_command_timeout, 4242);
7009        let Some(ClusterConfig::Http(app)) = config.clusters.get("app") else {
7010            panic!("the cluster must still be validated and built");
7011        };
7012        assert_eq!(app.backends.len(), 1, "only the literal is kept");
7013    }
7014
7015    /// #347: an address that is neither a socket address nor a resolvable
7016    /// `host:port` refuses the file with an error naming the cluster and the
7017    /// entry.
7018    #[test]
7019    fn an_unresolvable_backend_address_is_a_config_error() {
7020        for address in ["localhost", "localhost:notaport"] {
7021            let error = http_cluster_from_backends(&format!(r#"{{ address = "{address}" }}"#))
7022                .expect_err("the address must not resolve");
7023            let ConfigError::BackendAddressResolution {
7024                cluster_id,
7025                address: entry,
7026                ..
7027            } = &error
7028            else {
7029                panic!("{address}: unexpected error {error:?}");
7030            };
7031            assert_eq!(cluster_id, "cluster");
7032            assert_eq!(entry, address);
7033            assert!(error.to_string().contains(address), "{error}");
7034        }
7035    }
7036
7037    /// #152 + #347: every address a name expands to, and a socket-address
7038    /// literal, keeps the entry's `unix_path`.
7039    #[test]
7040    fn every_expanded_backend_keeps_its_unix_path() {
7041        let entry = |address: &str| FileBackendConfig {
7042            address: address.to_owned(),
7043            weight: None,
7044            sticky_id: None,
7045            backup: None,
7046            backend_id: None,
7047            unix_path: Some("/run/app.sock".to_owned()),
7048        };
7049        let v4: SocketAddr = "10.0.0.2:8080".parse().unwrap();
7050        let v6: SocketAddr = "[fd00::1]:8080".parse().unwrap();
7051        let expanded = entry("ingress:8080")
7052            .expand("cluster", vec![v4, v6])
7053            .expect("two addresses expand");
7054        assert_eq!(expanded.len(), 2);
7055        assert!(
7056            expanded
7057                .iter()
7058                .all(|backend| backend.unix_path.as_deref() == Some("/run/app.sock"))
7059        );
7060
7061        let literal = entry("127.0.0.1:8080")
7062            .resolve("cluster", 0, &system_resolver)
7063            .expect("a literal resolves");
7064        assert_eq!(literal[0].unix_path.as_deref(), Some("/run/app.sock"));
7065    }
7066
7067    /// #347: a name resolving to several addresses yields one backend per
7068    /// distinct address, sorted, each with a distinct id and, when one is
7069    /// configured, a distinct sticky id.
7070    #[test]
7071    fn a_name_with_several_addresses_expands_into_distinct_backends() {
7072        let entry = FileBackendConfig {
7073            address: "ingress:8080".to_owned(),
7074            weight: None,
7075            sticky_id: Some("pinned".to_owned()),
7076            backup: Some(true),
7077            backend_id: Some("ingress".to_owned()),
7078            unix_path: None,
7079        };
7080        let v4: SocketAddr = "10.0.0.2:8080".parse().unwrap();
7081        let v6: SocketAddr = "[fd00::1]:8080".parse().unwrap();
7082        let backends = entry
7083            .expand("cluster", vec![v6, v4, v6])
7084            .expect("two addresses expand");
7085        assert_eq!(
7086            backends,
7087            vec![
7088                BackendConfig {
7089                    address: v4,
7090                    weight: None,
7091                    sticky_id: Some(format!("pinned-{v4}")),
7092                    backup: Some(true),
7093                    backend_id: Some(format!("ingress-{v4}")),
7094                    unix_path: None,
7095                },
7096                BackendConfig {
7097                    address: v6,
7098                    weight: None,
7099                    sticky_id: Some(format!("pinned-{v6}")),
7100                    backup: Some(true),
7101                    backend_id: Some(format!("ingress-{v6}")),
7102                    unix_path: None,
7103                },
7104            ]
7105        );
7106
7107        // The sticky id does not depend on how many addresses came back.
7108        let single = entry.expand("cluster", vec![v4]).expect("one address");
7109        assert_eq!(single[0].sticky_id, Some(format!("pinned-{v4}")));
7110        assert!(matches!(
7111            entry.expand("cluster", Vec::new()),
7112            Err(ConfigError::BackendAddressResolution { .. })
7113        ));
7114    }
7115
7116    /// #524: `affinity_header` travels from the TOML file to the
7117    /// `AddCluster` order the workers receive.
7118    #[test]
7119    fn an_affinity_header_reaches_the_add_cluster_order() {
7120        let file: FileClusterConfig = toml::from_str(
7121            r#"
7122            protocol = "http"
7123            load_balancing = "HRW"
7124            affinity_header = "X-Tenant"
7125            frontends = []
7126            backends = []
7127            "#,
7128        )
7129        .expect("the cluster must parse");
7130        let ClusterConfig::Http(http) = file
7131            .to_cluster_config("keyed", &HashSet::new())
7132            .expect("the cluster must build")
7133        else {
7134            panic!("an http cluster must build as one");
7135        };
7136        let orders = http.generate_requests().expect("the orders must build");
7137        let Some(RequestType::AddCluster(cluster)) =
7138            orders.first().and_then(|order| order.request_type.as_ref())
7139        else {
7140            panic!("the orders must lead with an AddCluster");
7141        };
7142        assert_eq!(cluster.affinity_header.as_deref(), Some("X-Tenant"));
7143        assert_eq!(cluster.affinity_cookie, None);
7144    }
7145
7146    /// #152: a backend's `unix_path` travels from the TOML file to the
7147    /// `AddUnixBackend` order, next to the identity `address`; a backend
7148    /// without one keeps `AddBackend`.
7149    #[test]
7150    fn a_unix_path_backend_reaches_the_add_unix_backend_order() {
7151        let file: FileClusterConfig = toml::from_str(
7152            r#"
7153            protocol = "http"
7154            frontends = []
7155            backends = [
7156                { address = "127.0.0.1:1", unix_path = "/run/app.sock" },
7157                { address = "127.0.0.1:2" },
7158            ]
7159            "#,
7160        )
7161        .expect("the cluster must parse");
7162        let ClusterConfig::Http(http) = file
7163            .to_cluster_config("app", &HashSet::new())
7164            .expect("the cluster must build")
7165        else {
7166            panic!("an http cluster must build as one");
7167        };
7168        let unix_paths: Vec<_> = http
7169            .generate_requests()
7170            .expect("the orders must build")
7171            .into_iter()
7172            .filter_map(|order| match order.request_type {
7173                Some(RequestType::AddUnixBackend(backend)) => {
7174                    Some(("AddUnixBackend", backend.unix_path))
7175                }
7176                Some(RequestType::AddBackend(backend)) => Some(("AddBackend", backend.unix_path)),
7177                _ => None,
7178            })
7179            .collect();
7180        assert_eq!(
7181            unix_paths,
7182            vec![
7183                ("AddUnixBackend", Some("/run/app.sock".to_owned())),
7184                ("AddBackend", None)
7185            ]
7186        );
7187    }
7188
7189    /// #152: sozu tells backends apart by address, so a unix backend's
7190    /// identity address may not be shared with another backend.
7191    #[test]
7192    fn a_unix_path_backend_needs_a_unique_address() {
7193        for protocol in ["http", "tcp"] {
7194            let file: FileClusterConfig = toml::from_str(&format!(
7195                "protocol = \"{protocol}\"\nfrontends = []\nbackends = [\n\
7196                 {{ address = \"127.0.0.1:1\", unix_path = \"/run/a.sock\" }},\n\
7197                 {{ address = \"127.0.0.1:1\", unix_path = \"/run/b.sock\" }},\n]\n"
7198            ))
7199            .expect("the cluster must parse");
7200            assert!(
7201                matches!(
7202                    file.to_cluster_config("app", &HashSet::new()),
7203                    Err(ConfigError::InvalidBackendUnixPath { .. })
7204                ),
7205                "a {protocol} cluster must refuse a shared unix backend address"
7206            );
7207        }
7208    }
7209
7210    /// A probe `port` targets a backend IP and a `unix_path` backend has none:
7211    /// the file refuses the pair, whichever protocol, whether the entry is a
7212    /// literal or a name DNS expands, and whether or not a resolver runs.
7213    #[test]
7214    fn a_health_check_port_and_a_unix_path_backend_never_share_a_file_cluster() {
7215        let resolver = |_: &str| -> std::io::Result<Vec<SocketAddr>> {
7216            Ok(vec!["10.0.0.2:8080".parse().unwrap()])
7217        };
7218        for protocol in ["http", "tcp"] {
7219            for address in ["127.0.0.1:1", "ingress:8080"] {
7220                let build = |health_check: &str, resolver: Option<&BackendResolver<'_>>| {
7221                    toml::from_str::<FileClusterConfig>(&format!(
7222                        "protocol = \"{protocol}\"\nfrontends = []\nbackends = [\n\
7223                         {{ address = \"{address}\", unix_path = \"/run/app.sock\" }},\n]\n\
7224                         [health_check]\n{health_check}"
7225                    ))
7226                    .expect("the cluster must parse")
7227                    .to_cluster_config_with("app", &HashSet::new(), resolver)
7228                };
7229                for resolver in [Some(&resolver as &BackendResolver<'_>), None] {
7230                    let error = build("mode = \"TCP\"\nport = 8081\n", resolver)
7231                        .expect_err("a probe port next to a unix backend must be refused");
7232                    assert!(
7233                        matches!(
7234                            &error,
7235                            ConfigError::InvalidHealthCheck { cluster_id, reason }
7236                                if cluster_id == "app" && *reason == HEALTH_CHECK_PORT_UNIX_REFUSED
7237                        ),
7238                        "{protocol} {address}: unexpected {error:?}"
7239                    );
7240                    build("mode = \"TCP\"\n", resolver)
7241                        .expect("a unix backend without a probe port stays valid");
7242                }
7243            }
7244        }
7245    }
7246
7247    /// #152: what `parse_backend_unix_path` accepts and refuses.
7248    #[test]
7249    fn parse_backend_unix_path_accepts_absolute_and_abstract_names_only() {
7250        let path = parse_backend_unix_path("/run/app.sock").expect("an absolute path parses");
7251        assert_eq!(
7252            path.as_pathname(),
7253            Some(std::path::Path::new("/run/app.sock"))
7254        );
7255        #[cfg(target_os = "linux")]
7256        {
7257            use std::os::linux::net::SocketAddrExt;
7258            let named = parse_backend_unix_path("@app").expect("an abstract name parses");
7259            assert_eq!(named.as_abstract_name(), Some(&b"app"[..]));
7260            assert_eq!(named.as_pathname(), None);
7261        }
7262        let too_long = format!("/{}", "a".repeat(200));
7263        for refused in ["", "run/app.sock", "@", "/run/a\0b", too_long.as_str()] {
7264            assert!(
7265                parse_backend_unix_path(refused).is_err(),
7266                "{refused:?} must be refused"
7267            );
7268        }
7269    }
7270
7271    /// #1253: `header_predicates` travel from the TOML file to the
7272    /// `AddHttpFrontend` order, `match` parsed case-insensitively.
7273    #[test]
7274    fn header_predicates_reach_the_add_http_frontend_order() {
7275        let file: FileClusterConfig = toml::from_str(
7276            r#"
7277            protocol = "http"
7278            frontends = [
7279              { address = "0.0.0.0:80", hostname = "api.example.com", header_predicates = [
7280                { name = "X-Canary", match = "EXACT", value = "always" },
7281                { name = "X-Region", match = "regex", value = "eu-.*" },
7282                { name = "X-Beta", match = "present" },
7283              ] },
7284            ]
7285            backends = []
7286            "#,
7287        )
7288        .expect("the cluster must parse");
7289        let ClusterConfig::Http(http) = file
7290            .to_cluster_config("canary", &HashSet::new())
7291            .expect("the cluster must build")
7292        else {
7293            panic!("an http cluster must build as one");
7294        };
7295        let orders = http.generate_requests().expect("the orders must build");
7296        let front = orders
7297            .iter()
7298            .find_map(|order| match order.request_type.as_ref() {
7299                Some(RequestType::AddHttpFrontendWithHeaderPredicates(front)) => Some(front),
7300                _ => None,
7301            })
7302            .expect("the orders must add the frontend");
7303        let predicate = |name: &str, kind: HeaderMatchKind, value: Option<&str>| HeaderPredicate {
7304            name: name.to_owned(),
7305            kind: kind as i32,
7306            value: value.map(str::to_owned),
7307        };
7308        assert_eq!(
7309            front.header_predicates,
7310            vec![
7311                predicate("X-Canary", HeaderMatchKind::Exact, Some("always")),
7312                predicate("X-Region", HeaderMatchKind::Regex, Some("eu-.*")),
7313                predicate("X-Beta", HeaderMatchKind::Present, None),
7314            ]
7315        );
7316    }
7317
7318    /// #1253: a predicate no request could satisfy is refused at config
7319    /// load with its index, never left to silently match nothing.
7320    #[test]
7321    fn unusable_header_predicates_are_refused_at_config_load() {
7322        for entry in [
7323            r#"{ name = "X-Canary", match = "equals", value = "1" }"#,
7324            r#"{ name = "Host", match = "exact", value = "example.com" }"#,
7325            r#"{ name = "Cookie", match = "present" }"#,
7326            r#"{ name = "X-Canary", match = "present", value = "1" }"#,
7327            r#"{ name = "X-Canary", match = "exact" }"#,
7328            r#"{ name = "X Canary", match = "present" }"#,
7329        ] {
7330            let file: FileClusterConfig = toml::from_str(&format!(
7331                r#"
7332                protocol = "http"
7333                frontends = [
7334                  {{ address = "0.0.0.0:80", hostname = "api.example.com", header_predicates = [
7335                    {{ name = "X-Valid", match = "present" }},
7336                    {entry},
7337                  ] }},
7338                ]
7339                backends = []
7340                "#
7341            ))
7342            .expect("the cluster must parse");
7343            match file.to_cluster_config("canary", &HashSet::new()) {
7344                Err(ConfigError::InvalidHeaderPredicate { index: 1, .. }) => {}
7345                other => panic!("{entry}: expected InvalidHeaderPredicate at 1, got {other:?}"),
7346            }
7347        }
7348    }
7349
7350    /// #524: a TCP cluster has no request to read a header or a cookie
7351    /// from, so declaring one is refused rather than silently ignored.
7352    #[test]
7353    fn a_tcp_cluster_refuses_an_affinity_header_or_cookie() {
7354        for key in [
7355            "affinity_header = \"X-Tenant\"",
7356            "affinity_cookie = \"tenant\"",
7357        ] {
7358            let file: FileClusterConfig = toml::from_str(&format!(
7359                "protocol = \"tcp\"\nload_balancing = \"MAGLEV\"\n{key}\nfrontends = []\nbackends = []\n"
7360            ))
7361            .expect("the cluster must parse");
7362            assert!(
7363                matches!(
7364                    file.to_cluster_config("keyed", &HashSet::new()),
7365                    Err(ConfigError::InvalidAffinityKey { .. })
7366                ),
7367                "a TCP cluster must refuse `{key}`"
7368            );
7369        }
7370    }
7371
7372    /// #524: at most one key source, and a name a request can carry.
7373    #[test]
7374    fn validate_affinity_key_accepts_one_token_and_refuses_the_rest() {
7375        assert!(validate_affinity_key(None, None).is_ok());
7376        assert!(validate_affinity_key(Some("X-Tenant"), None).is_ok());
7377        assert!(validate_affinity_key(None, Some("tenant_id")).is_ok());
7378        assert!(validate_affinity_key(Some("X-Tenant"), Some("tenant")).is_err());
7379        for unreachable in ["Host", "host", "HOST", "Cookie", "cookie"] {
7380            assert!(
7381                validate_affinity_key(Some(unreachable), None).is_err(),
7382                "header {unreachable:?} can never be found and must be refused"
7383            );
7384        }
7385        // A cookie merely NAMED like those headers is an ordinary cookie.
7386        assert!(validate_affinity_key(None, Some("host")).is_ok());
7387        for bad in ["", "X Tenant", "X-Tenant:", "tenant\r\n", "t=1"] {
7388            assert!(
7389                validate_affinity_key(Some(bad), None).is_err(),
7390                "header {bad:?} must be refused"
7391            );
7392            assert!(
7393                validate_affinity_key(None, Some(bad)).is_err(),
7394                "cookie {bad:?} must be refused"
7395            );
7396        }
7397    }
7398
7399    /// #524: shard knobs travel from the TOML file to the `AddCluster`
7400    /// order, and an invalid combination is refused at load.
7401    #[test]
7402    fn shuffle_sharding_reaches_the_add_cluster_order_and_is_validated() {
7403        let parse = |extra: &str| -> FileClusterConfig {
7404            toml::from_str(&format!(
7405                "protocol = \"http\"\nfrontends = []\nbackends = []\n{extra}"
7406            ))
7407            .expect("the cluster must parse")
7408        };
7409        let ClusterConfig::Http(http) =
7410            parse("shard_percent = 25\nshard_min_backends = 4\nshard_mode = \"STRICT\"\n")
7411                .to_cluster_config("sharded", &HashSet::new())
7412                .expect("a valid sharded cluster builds")
7413        else {
7414            panic!("an http cluster must build as one");
7415        };
7416        let orders = http.generate_requests().expect("the orders must build");
7417        let Some(RequestType::AddCluster(cluster)) =
7418            orders.first().and_then(|order| order.request_type.as_ref())
7419        else {
7420            panic!("the orders must lead with an AddCluster");
7421        };
7422        assert_eq!(
7423            (
7424                cluster.shard_percent,
7425                cluster.shard_min_backends,
7426                cluster.shard_mode
7427            ),
7428            (Some(25), Some(4), Some(ShardMode::Strict as i32))
7429        );
7430        for bad in [
7431            "shard_percent = 0\n",
7432            "shard_percent = 101\n",
7433            "shard_percent = 25\nshard_min_backends = 1\n",
7434            "shard_min_backends = 8\n",
7435            "shard_mode = \"STRICT\"\n",
7436        ] {
7437            assert!(
7438                matches!(
7439                    parse(bad).to_cluster_config("sharded", &HashSet::new()),
7440                    Err(ConfigError::InvalidShuffleSharding { .. })
7441                ),
7442                "{bad:?} must be refused"
7443            );
7444        }
7445    }
7446
7447    /// #1801: `accepted_statuses` entries parse to inclusive ranges, and
7448    /// everything outside the documented syntax or `100..=599` is refused.
7449    #[test]
7450    fn http_status_range_parses_codes_ranges_classes_and_any() {
7451        let range = |start, end| HttpStatusRange { start, end };
7452        for (spec, expected) in [
7453            ("404", range(404, 404)),
7454            ("200-399", range(200, 399)),
7455            (" 200 - 399 ", range(200, 399)),
7456            ("2xx", range(200, 299)),
7457            ("5XX", range(500, 599)),
7458            ("any", range(100, 599)),
7459            ("ANY", range(100, 599)),
7460            ("100-599", range(100, 599)),
7461        ] {
7462            assert_eq!(parse_http_status_range(spec), Ok(expected), "{spec:?}");
7463        }
7464        for spec in [
7465            "", "abc", "99", "600", "0", "6xx", "0xx", "2x", "2xxx", "399-200", "200-", "-200",
7466            "+200", "200-600", "1-2-3", "2xx-3xx",
7467        ] {
7468            assert!(
7469                parse_http_status_range(spec).is_err(),
7470                "{spec:?} must be refused"
7471            );
7472        }
7473    }
7474
7475    /// #1801: the rendering used by `sozu cluster health-check list` parses
7476    /// back to the same range.
7477    #[test]
7478    fn http_status_range_display_round_trips_through_the_parser() {
7479        for spec in ["404", "200-399", "2xx", "any", "101-102"] {
7480            let range = parse_http_status_range(spec).unwrap();
7481            assert_eq!(range.to_string(), spec);
7482            assert_eq!(parse_http_status_range(&range.to_string()), Ok(range));
7483        }
7484    }
7485
7486    /// #1801: validation of the probe mode, the TCP-mode URI exemption, the
7487    /// range bounds and the `expected_status` / `accepted_statuses`
7488    /// exclusion — the rules shared by the CLI, the TOML loader, the master
7489    /// state and the worker.
7490    #[test]
7491    fn health_check_validation_covers_mode_uri_and_accepted_statuses() {
7492        let http = HealthCheckConfig {
7493            uri: "/health".to_owned(),
7494            interval: 10,
7495            timeout: 5,
7496            healthy_threshold: 3,
7497            unhealthy_threshold: 3,
7498            expected_status: 0,
7499            mode: HealthCheckMode::Http as i32,
7500            accepted_statuses: Vec::new(),
7501            port: None,
7502            headers: Vec::new(),
7503        };
7504        assert_eq!(validate_health_check_config(&http), Ok(()));
7505
7506        // TCP mode ignores the URI, so an empty one is fine; HTTP mode is not.
7507        let tcp_no_uri = HealthCheckConfig {
7508            uri: String::new(),
7509            mode: HealthCheckMode::Tcp as i32,
7510            ..http.clone()
7511        };
7512        assert_eq!(validate_health_check_config(&tcp_no_uri), Ok(()));
7513        let http_no_uri = HealthCheckConfig {
7514            uri: String::new(),
7515            ..http.clone()
7516        };
7517        assert!(validate_health_check_config(&http_no_uri).is_err());
7518        // Control bytes are refused in every mode.
7519        let tcp_crlf = HealthCheckConfig {
7520            uri: "/a\r\nGET /b".to_owned(),
7521            ..tcp_no_uri.clone()
7522        };
7523        assert!(validate_health_check_config(&tcp_crlf).is_err());
7524
7525        // TCP mode judges no status, so status fields beside it are refused.
7526        let tcp_expected = HealthCheckConfig {
7527            expected_status: 200,
7528            ..tcp_no_uri.clone()
7529        };
7530        assert!(validate_health_check_config(&tcp_expected).is_err());
7531        let tcp_accepted = HealthCheckConfig {
7532            accepted_statuses: vec![HttpStatusRange {
7533                start: 100,
7534                end: 599,
7535            }],
7536            ..tcp_no_uri.clone()
7537        };
7538        assert!(validate_health_check_config(&tcp_accepted).is_err());
7539
7540        let unknown_mode = HealthCheckConfig {
7541            mode: 7,
7542            ..http.clone()
7543        };
7544        assert!(validate_health_check_config(&unknown_mode).is_err());
7545
7546        let accepted = HealthCheckConfig {
7547            accepted_statuses: vec![HttpStatusRange {
7548                start: 200,
7549                end: 499,
7550            }],
7551            ..http.clone()
7552        };
7553        assert_eq!(validate_health_check_config(&accepted), Ok(()));
7554        let both = HealthCheckConfig {
7555            expected_status: 200,
7556            ..accepted.clone()
7557        };
7558        assert!(
7559            validate_health_check_config(&both).is_err(),
7560            "expected_status and accepted_statuses must not both be set"
7561        );
7562        for (start, end) in [(99, 200), (200, 600), (300, 200)] {
7563            let bad_range = HealthCheckConfig {
7564                accepted_statuses: vec![HttpStatusRange { start, end }],
7565                ..http.clone()
7566            };
7567            assert!(
7568                validate_health_check_config(&bad_range).is_err(),
7569                "{start}-{end} must be refused"
7570            );
7571        }
7572    }
7573
7574    /// #1801: the TOML block defaults to HTTP mode on `/`, accepts
7575    /// `mode = "TCP"` without a `uri`, carries `accepted_statuses` to the
7576    /// `AddCluster` order, and refuses a bad block at load.
7577    #[test]
7578    fn health_check_toml_mode_and_accepted_statuses_reach_the_add_cluster_order() {
7579        let parse = |block: &str| -> FileClusterConfig {
7580            toml::from_str(&format!(
7581                "protocol = \"http\"\nfrontends = []\nbackends = []\n[health_check]\n{block}"
7582            ))
7583            .expect("the cluster must parse")
7584        };
7585        let health_check = |block: &str| -> HealthCheckConfig {
7586            let ClusterConfig::Http(http) = parse(block)
7587                .to_cluster_config("checked", &HashSet::new())
7588                .expect("a valid health check builds")
7589            else {
7590                panic!("an http cluster must build as one");
7591            };
7592            let orders = http.generate_requests().expect("the orders must build");
7593            let Some(RequestType::AddCluster(cluster)) =
7594                orders.first().and_then(|order| order.request_type.as_ref())
7595            else {
7596                panic!("the orders must lead with an AddCluster");
7597            };
7598            cluster
7599                .health_check
7600                .clone()
7601                .expect("the health check must travel")
7602        };
7603
7604        // A legacy block keeps its meaning: HTTP mode, no list.
7605        let legacy = health_check("uri = \"/health\"\nexpected_status = 204\n");
7606        assert_eq!(legacy.mode, HealthCheckMode::Http as i32);
7607        assert_eq!(
7608            (legacy.uri.as_str(), legacy.expected_status),
7609            ("/health", 204)
7610        );
7611        assert!(legacy.accepted_statuses.is_empty());
7612
7613        let defaulted = health_check("");
7614        assert_eq!(defaulted.mode, HealthCheckMode::Http as i32);
7615        assert_eq!(defaulted.uri, "/");
7616
7617        let tcp = health_check("mode = \"TCP\"\n");
7618        assert_eq!(tcp.mode, HealthCheckMode::Tcp as i32);
7619
7620        let accepted = health_check("uri = \"/livez\"\naccepted_statuses = [\"2xx\", \"404\"]\n");
7621        assert_eq!(
7622            accepted.accepted_statuses,
7623            vec![
7624                HttpStatusRange {
7625                    start: 200,
7626                    end: 299
7627                },
7628                HttpStatusRange {
7629                    start: 404,
7630                    end: 404
7631                },
7632            ]
7633        );
7634
7635        for bad in [
7636            "accepted_statuses = [\"6xx\"]\n",
7637            "expected_status = 200\naccepted_statuses = [\"any\"]\n",
7638            "uri = \"health\"\n",
7639            "mode = \"TCP\"\nexpected_status = 200\n",
7640            "mode = \"TCP\"\naccepted_statuses = [\"any\"]\n",
7641        ] {
7642            assert!(
7643                matches!(
7644                    parse(bad).to_cluster_config("checked", &HashSet::new()),
7645                    Err(ConfigError::InvalidHealthCheck { .. })
7646                ),
7647                "{bad:?} must be refused"
7648            );
7649        }
7650    }
7651
7652    /// The probe `port` is optional; when set it must be a non-zero 16-bit
7653    /// port, in either mode.
7654    #[test]
7655    fn health_check_validation_bounds_the_probe_port() {
7656        for mode in [HealthCheckMode::Http, HealthCheckMode::Tcp] {
7657            let config = |port: Option<u32>| HealthCheckConfig {
7658                uri: "/health".to_owned(),
7659                interval: 10,
7660                timeout: 5,
7661                healthy_threshold: 3,
7662                unhealthy_threshold: 3,
7663                mode: mode as i32,
7664                port,
7665                ..Default::default()
7666            };
7667            for port in [None, Some(1), Some(8080), Some(65535)] {
7668                assert_eq!(
7669                    validate_health_check_config(&config(port)),
7670                    Ok(()),
7671                    "{mode:?} probe port {port:?} must be accepted"
7672                );
7673            }
7674            for port in [0, 65536, u32::MAX] {
7675                assert_eq!(
7676                    validate_health_check_config(&config(Some(port))),
7677                    Err("health check port must lie in 1..=65535"),
7678                    "{mode:?} probe port {port} must be refused"
7679                );
7680            }
7681        }
7682    }
7683
7684    /// The TOML `port` reaches the `AddCluster` order, an absent one stays
7685    /// unset, and an out-of-range one is refused at load.
7686    #[test]
7687    fn health_check_toml_port_reaches_the_add_cluster_order() {
7688        let build = |block: &str| {
7689            toml::from_str::<FileClusterConfig>(&format!(
7690                "protocol = \"tcp\"\nfrontends = []\nbackends = []\n[health_check]\n{block}"
7691            ))
7692            .expect("the cluster must parse")
7693            .to_cluster_config("checked", &HashSet::new())
7694        };
7695        let port = |block: &str| -> Option<u32> {
7696            let Ok(ClusterConfig::Tcp(tcp)) = build(block) else {
7697                panic!("a tcp cluster with a valid health check must build as one");
7698            };
7699            tcp.health_check.expect("the health check must travel").port
7700        };
7701
7702        assert_eq!(port("mode = \"TCP\"\n"), None);
7703        assert_eq!(port("mode = \"TCP\"\nport = 8081\n"), Some(8081));
7704        assert_eq!(port("uri = \"/health\"\nport = 65535\n"), Some(65535));
7705        for bad in ["port = 0\n", "port = 65536\n"] {
7706            assert!(
7707                matches!(build(bad), Err(ConfigError::InvalidHealthCheck { .. })),
7708                "{bad:?} must be refused"
7709            );
7710        }
7711    }
7712
7713    /// A valid HTTP health check carrying `headers`, for the header tests.
7714    fn health_check_with_headers(headers: &[(&str, &str)]) -> HealthCheckConfig {
7715        HealthCheckConfig {
7716            uri: "/".to_owned(),
7717            interval: 10,
7718            timeout: 5,
7719            healthy_threshold: 3,
7720            unhealthy_threshold: 3,
7721            expected_status: 0,
7722            mode: HealthCheckMode::Http as i32,
7723            accepted_statuses: Vec::new(),
7724            port: None,
7725            headers: headers
7726                .iter()
7727                .map(|(name, _)| HealthCheckHeader {
7728                    name: (*name).to_owned(),
7729                    fingerprint: String::new(),
7730                })
7731                .collect(),
7732        }
7733    }
7734
7735    /// Names, then values, through both validators a protected order meets.
7736    fn check_headers(headers: &[(&str, &str)]) -> Result<(), &'static str> {
7737        let config = health_check_with_headers(headers);
7738        validate_health_check_config(&config)?;
7739        let values: Vec<HealthCheckHeaderValue> = headers
7740            .iter()
7741            .map(|(name, value)| HealthCheckHeaderValue {
7742                name: (*name).to_owned(),
7743                value: value.as_bytes().to_vec(),
7744            })
7745            .collect();
7746        validate_health_check_header_values(&config, &values)
7747    }
7748
7749    /// RFC 9113 §8.2.1: a field value must not begin or end with SP or HTAB.
7750    /// Direct protobuf and TOML values keep their exact bytes, so a value
7751    /// that would need trimming is refused rather than silently changed; an
7752    /// empty value and inner whitespace stay valid.
7753    #[test]
7754    fn health_check_header_value_refuses_leading_and_trailing_ows() {
7755        for value in [" v", "v ", "\tv", "v\t", " ", "\t", " v ", "a b \t"] {
7756            assert_eq!(
7757                check_headers(&[("x-a", value)]),
7758                Err("health check header value must not begin or end with a space or a tab"),
7759                "{value:?} must be refused"
7760            );
7761        }
7762        for value in ["", "v", "a b", "a\tb", "é ü", "\u{a0}v"] {
7763            assert_eq!(
7764                check_headers(&[("x-a", value)]),
7765                Ok(()),
7766                "{value:?} must be accepted"
7767            );
7768        }
7769    }
7770
7771    /// Every refusal class of the custom health-check headers, and the edge
7772    /// each bound accepts.
7773    #[test]
7774    fn health_check_header_validation_covers_every_class() {
7775        let refused = |headers: &[(&str, &str)], reason: &str| {
7776            assert_eq!(
7777                check_headers(headers),
7778                Err(reason),
7779                "{headers:?} must be refused"
7780            );
7781        };
7782
7783        // Accepted shapes: ordered, mixed case, empty value, tab inside,
7784        // obs-text (valid UTF-8 above 0x7F).
7785        assert_eq!(
7786            check_headers(&[
7787                ("Authorization", "Bearer t"),
7788                ("X-Empty", ""),
7789                ("x-tab", "a\tb"),
7790                ("x-utf8", "caf\u{e9} \u{1F600}"),
7791            ]),
7792            Ok(())
7793        );
7794
7795        // TCP mode sends no request.
7796        let mut tcp = health_check_with_headers(&[("x-a", "1")]);
7797        tcp.mode = HealthCheckMode::Tcp as i32;
7798        assert_eq!(
7799            validate_health_check_config(&tcp),
7800            Err("health check headers apply to HTTP mode only: leave them unset with mode TCP")
7801        );
7802
7803        // Count: 32 pass, 33 do not.
7804        let names: Vec<String> = (0..33).map(|i| format!("x-{i}")).collect();
7805        let many = |count: usize| -> Vec<(&str, &str)> {
7806            names[..count].iter().map(|n| (n.as_str(), "v")).collect()
7807        };
7808        assert_eq!(check_headers(&many(32)), Ok(()));
7809        refused(&many(33), "health check carries more than 32 headers");
7810
7811        // Reserved names, whatever the case, and pseudo-header names.
7812        for name in [
7813            ":authority",
7814            ":x",
7815            "Host",
7816            "HOST",
7817            "connection",
7818            "Keep-Alive",
7819            "proxy-connection",
7820            "Transfer-Encoding",
7821            "content-length",
7822            "TE",
7823            "trailer",
7824            "upgrade",
7825            "Expect",
7826        ] {
7827            refused(
7828                &[(name, "v")],
7829                "health check header name is reserved for the probe or its framing",
7830            );
7831        }
7832
7833        // Names that are not an RFC 9110 token.
7834        for name in [
7835            "",
7836            " x",
7837            "x ",
7838            "a b",
7839            "a:b",
7840            "caf\u{e9}",
7841            "a\r",
7842            "a\"b",
7843            "a(b",
7844        ] {
7845            refused(
7846                &[(name, "v")],
7847                "health check header name must be a non-empty RFC 9110 token",
7848            );
7849        }
7850
7851        // Control bytes: CR, LF, NUL, other C0 except HTAB, DEL.
7852        for value in [
7853            "a\rb", "a\nb", "a\0b", "a\x01b", "a\x08b", "a\x0bb", "a\x1fb", "a\x7fb",
7854        ] {
7855            refused(
7856                &[("x-a", value)],
7857                "health check header value must not contain control bytes",
7858            );
7859        }
7860
7861        // Duplicate names compare case-insensitively.
7862        refused(
7863            &[("X-Token", "1"), ("x-token", "2")],
7864            "health check header names must be unique, compared case-insensitively",
7865        );
7866
7867        // The 8192-byte wire sum is name + value + 4 per header.
7868        let value_fits = "v".repeat(8192 - 1 - 4);
7869        assert_eq!(check_headers(&[("x", &value_fits)]), Ok(()));
7870        let value_over = "v".repeat(8192 - 1 - 4 + 1);
7871        refused(
7872            &[("x", &value_over)],
7873            "health check headers exceed 8192 bytes (name + value + 4 per header)",
7874        );
7875
7876        // The HTTP/2 block bound counts the URI as well: the URI alone, or the
7877        // headers alone, pass; together they may exceed one HEADERS frame.
7878        let mut long_uri = health_check_with_headers(&[]);
7879        long_uri.uri = format!("/{}", "a".repeat(9000));
7880        assert_eq!(validate_health_check_config(&long_uri), Ok(()));
7881        long_uri.headers = vec![HealthCheckHeader {
7882            name: "x".to_owned(),
7883            fingerprint: String::new(),
7884        }];
7885        assert_eq!(validate_health_check_config(&long_uri), Ok(()));
7886        let carrier = HealthCheckHeaderValue {
7887            name: "x".to_owned(),
7888            value: "v".repeat(8000).into_bytes(),
7889        };
7890        assert_eq!(
7891            validate_health_check_header_values(&long_uri, &[carrier]),
7892            Err("health check URI and headers may exceed the 16384-byte HTTP/2 probe block")
7893        );
7894
7895        // A value that does not follow its header's name, count or order is
7896        // refused, and so is one that is not UTF-8, without echoing either.
7897        let one = health_check_with_headers(&[("x-a", ""), ("x-b", "")]);
7898        let carrier = |name: &str, value: &[u8]| HealthCheckHeaderValue {
7899            name: name.to_owned(),
7900            value: value.to_vec(),
7901        };
7902        assert_eq!(
7903            validate_health_check_header_values(&one, &[carrier("x-a", b"1")]),
7904            Err("health check carries a header value count different from its header count")
7905        );
7906        assert_eq!(
7907            validate_health_check_header_values(
7908                &one,
7909                &[carrier("x-b", b"1"), carrier("x-a", b"2")]
7910            ),
7911            Err("health check header values must follow the order and names of the headers")
7912        );
7913        assert_eq!(
7914            validate_health_check_header_values(
7915                &one,
7916                &[carrier("x-a", b"1"), carrier("x-b", &[0xff])]
7917            ),
7918            Err("health check header value must be valid UTF-8")
7919        );
7920        // A fingerprint that is not one is no metadata.
7921        let mut spoofed = health_check_with_headers(&[("x-a", "")]);
7922        spoofed.headers[0].fingerprint = "not a fingerprint".to_owned();
7923        assert_eq!(
7924            validate_health_check_config(&spoofed),
7925            Err("health check header fingerprint is malformed")
7926        );
7927    }
7928
7929    /// An older state record or peer carries no `headers`: it loads as an empty
7930    /// list. A list survives JSON (state file) and protobuf (command socket)
7931    /// round trips with its order and exact bytes.
7932    #[test]
7933    fn health_check_headers_default_empty_and_round_trip_in_order() {
7934        use prost::Message;
7935
7936        let config = health_check_with_headers(&[
7937            ("Z-Last-Alphabetically", "1"),
7938            ("authorization", "Bearer \u{e9}\tx"),
7939            ("A-First-Alphabetically", ""),
7940        ]);
7941
7942        // JSON without the key.
7943        let mut value = serde_json::to_value(&config).expect("serialize");
7944        value
7945            .as_object_mut()
7946            .expect("an object")
7947            .remove("headers")
7948            .expect("the key is serialized");
7949        let legacy: HealthCheckConfig = serde_json::from_value(value).expect("legacy record loads");
7950        assert!(legacy.headers.is_empty());
7951
7952        // JSON round trip.
7953        let json = serde_json::to_string(&config).expect("serialize");
7954        let back: HealthCheckConfig = serde_json::from_str(&json).expect("deserialize");
7955        assert_eq!(back, config);
7956        assert_eq!(
7957            back.headers
7958                .iter()
7959                .map(|h| h.name.as_str())
7960                .collect::<Vec<_>>(),
7961            [
7962                "Z-Last-Alphabetically",
7963                "authorization",
7964                "A-First-Alphabetically"
7965            ]
7966        );
7967
7968        // Protobuf: a message without field 10 decodes to an empty list, and
7969        // the list round-trips in order.
7970        let headerless = HealthCheckConfig {
7971            headers: Vec::new(),
7972            ..config.clone()
7973        };
7974        let decoded =
7975            HealthCheckConfig::decode(headerless.encode_to_vec().as_slice()).expect("decodes");
7976        assert!(decoded.headers.is_empty());
7977        let decoded =
7978            HealthCheckConfig::decode(config.encode_to_vec().as_slice()).expect("decodes");
7979        assert_eq!(decoded, config);
7980    }
7981
7982    /// `[[clusters.<id>.health_check.headers]]` keeps array order down to the
7983    /// metadata and the `value_file` references, is held to the name
7984    /// validator, and is refused where it would become a bare `AddCluster`
7985    /// name with no value.
7986    ///
7987    /// Contract change: this test used to give each header a literal `value`
7988    /// and read the clear values back from `to_protected_parts`, whose exact
7989    /// bytes and OWS rules it checked here. A configuration now holds no value,
7990    /// only a `value_file` reference that is stored unread, so the order, the
7991    /// empty fingerprints, the bare-conversion refusal, the name and mode
7992    /// refusals and the unknown-key refusal are asserted on `to_parts`; the
7993    /// byte-exact value rules stay covered by `check_headers` and move to the
7994    /// moment the file is read.
7995    #[test]
7996    fn health_check_toml_headers_split_into_metadata_and_ordered_references() {
7997        let parse = |block: &str| {
7998            toml::from_str::<FileHealthCheckConfig>(&format!("uri = \"/health\"\n{block}"))
7999                .expect("the table parses")
8000        };
8001
8002        let file = parse(
8003            "[[headers]]\nname = \"Z-Second\"\nvalue_file = \"/missing/z\"\n\
8004             [[headers]]\nname = \"Authorization\"\nvalue_file = \"rel/a\"\n\
8005             [[headers]]\nname = \"X-Empty\"\nvalue_file = \"/missing/e\"\n",
8006        );
8007        let (config, files) = file.to_parts().expect("a valid list builds");
8008        assert_eq!(
8009            config
8010                .headers
8011                .iter()
8012                .map(|h| (h.name.as_str(), h.fingerprint.as_str()))
8013                .collect::<Vec<_>>(),
8014            [("Z-Second", ""), ("Authorization", ""), ("X-Empty", "")],
8015            "metadata names the headers and carries no value or fingerprint"
8016        );
8017        assert_eq!(
8018            files
8019                .iter()
8020                .map(|f| (f.name.as_str(), f.value_file.as_str()))
8021                .collect::<Vec<_>>(),
8022            [
8023                ("Z-Second", "/missing/z"),
8024                ("Authorization", "rel/a"),
8025                ("X-Empty", "/missing/e")
8026            ]
8027        );
8028        // The bare conversion would leave names no probe could fill in.
8029        assert_eq!(
8030            file.to_proto(),
8031            Err(
8032                "health check headers from the configuration file are not available yet: their values need the protected channel"
8033            )
8034        );
8035
8036        // No `headers` key: the bare conversion still works.
8037        let bare = parse("");
8038        assert!(bare.to_proto().expect("builds").headers.is_empty());
8039        assert!(bare.to_parts().expect("builds").1.is_empty());
8040
8041        // Refusals: TCP mode, reserved name, duplicate name.
8042        for bad in [
8043            "mode = \"TCP\"\n[[headers]]\nname = \"x\"\nvalue_file = \"/v\"\n",
8044            "[[headers]]\nname = \"Host\"\nvalue_file = \"/v\"\n",
8045            "[[headers]]\nname = \"x\"\nvalue_file = \"/v\"\n\
8046             [[headers]]\nname = \"X\"\nvalue_file = \"/w\"\n",
8047        ] {
8048            assert!(parse(bad).to_parts().is_err(), "{bad:?} must be refused");
8049        }
8050        // An unknown table key is a parse error.
8051        assert!(
8052            toml::from_str::<FileHealthCheckConfig>(
8053                "uri = \"/\"\n[[headers]]\nname = \"x\"\nvalue_file = \"/v\"\nextra = 1\n"
8054            )
8055            .is_err()
8056        );
8057    }
8058
8059    /// Marker planted in every value the protected-header tests must never
8060    /// see echoed back, and in the reference path that Debug must redact.
8061    const HEADER_SENTINEL: &str = "SENTINEL-HEADER-VALUE-7f3a91";
8062    const REF_SENTINEL: &str = "sentinel-ref-dir-9c1e42";
8063
8064    /// A file cluster of `protocol` whose health check carries `headers`
8065    /// as `(name, value_file)` pairs, in order.
8066    fn value_file_cluster(protocol: &str, headers: &[(&str, &str)]) -> FileClusterConfig {
8067        let tables: String = headers
8068            .iter()
8069            .map(|(name, path)| {
8070                format!("[[health_check.headers]]\nname = \"{name}\"\nvalue_file = \"{path}\"\n")
8071            })
8072            .collect();
8073        toml::from_str(&format!(
8074            "protocol = \"{protocol}\"\nfrontends = []\nbackends = []\n\
8075             [health_check]\nuri = \"/health\"\n{tables}"
8076        ))
8077        .expect("a value_file reference parses")
8078    }
8079
8080    /// The JSON view of a built cluster, for asserting on serialized
8081    /// field names and order.
8082    fn cluster_json(cluster: &ClusterConfig) -> serde_json::Value {
8083        match cluster {
8084            ClusterConfig::Http(http) => serde_json::to_value(http),
8085            ClusterConfig::Tcp(tcp) => serde_json::to_value(tcp),
8086        }
8087        .expect("a cluster serializes")
8088    }
8089
8090    /// `value_file` is a reference: it is kept unchanged and in order, never
8091    /// opened (the paths below cannot exist), and reaches both cluster kinds
8092    /// next to the name-only metadata, through a `Config` serde round trip.
8093    #[test]
8094    fn health_check_value_file_is_kept_unopened_in_order_for_http_and_tcp() {
8095        let missing_a = format!("/{REF_SENTINEL}/missing/a.secret");
8096        let missing_b = format!("relative/../{REF_SENTINEL}/b.secret");
8097        let headers = [
8098            ("Z-Second", missing_a.as_str()),
8099            ("Authorization", &missing_b),
8100        ];
8101
8102        for protocol in ["http", "tcp"] {
8103            let file = value_file_cluster(protocol, &headers);
8104            let cluster = file
8105                .to_cluster_config("checked", &HashSet::new())
8106                .expect("a reference to a missing file builds: nothing opens it");
8107            let json = cluster_json(&cluster);
8108
8109            let metadata: Vec<(&str, &str)> = json["health_check"]["headers"]
8110                .as_array()
8111                .expect("the metadata lists the headers")
8112                .iter()
8113                .map(|h| {
8114                    (
8115                        h["name"].as_str().expect("a name"),
8116                        h["fingerprint"].as_str().expect("a fingerprint"),
8117                    )
8118                })
8119                .collect();
8120            assert_eq!(
8121                metadata,
8122                [("Z-Second", ""), ("Authorization", "")],
8123                "{protocol}: ordered names, empty fingerprints"
8124            );
8125            assert_eq!(
8126                json["health_check_header_files"],
8127                serde_json::json!([
8128                    { "name": "Z-Second", "value_file": missing_a },
8129                    { "name": "Authorization", "value_file": missing_b },
8130                ]),
8131                "{protocol}: references are stored unchanged and in order"
8132            );
8133
8134            let mut config = Config::default();
8135            config
8136                .clusters
8137                .insert("checked".to_owned(), cluster.clone());
8138            let text = serde_json::to_string(&config).expect("a Config serializes");
8139            let back: Config = serde_json::from_str(&text).expect("a Config deserializes");
8140            assert_eq!(
8141                back.clusters.get("checked"),
8142                Some(&cluster),
8143                "{protocol}: references survive Config serde"
8144            );
8145        }
8146
8147        // A cluster without headers carries no reference.
8148        let bare: FileClusterConfig = toml::from_str(
8149            "protocol = \"http\"\nfrontends = []\nbackends = []\n[health_check]\nuri = \"/\"\n",
8150        )
8151        .expect("parses");
8152        let built = bare
8153            .to_cluster_config("bare", &HashSet::new())
8154            .expect("builds");
8155        assert_eq!(
8156            cluster_json(&built)["health_check_header_files"],
8157            serde_json::json!([])
8158        );
8159    }
8160
8161    /// The old `value = "<secret>"` key and every other shape but a
8162    /// `value_file` reference are refused, and neither the parse diagnostic
8163    /// nor Debug echoes a value or a reference path.
8164    #[test]
8165    fn health_check_header_refuses_a_literal_or_env_value_and_never_echoes() {
8166        let parse = |table: &str| {
8167            toml::from_str::<FileHealthCheckConfig>(&format!("uri = \"/\"\n[[headers]]\n{table}\n"))
8168        };
8169
8170        for bad in [
8171            format!("name = \"x\"\nvalue = \"{HEADER_SENTINEL}\""),
8172            format!("name = \"x\"\nvalue_env = \"{HEADER_SENTINEL}\""),
8173            "name = \"x\"".to_owned(),
8174            "value_file = \"/p\"".to_owned(),
8175            format!("name = \"x\"\nvalue_file = \"/p\"\nvalue = \"{HEADER_SENTINEL}\""),
8176            format!("name = \"x\"\nvalue_file = \"/p\"\nvalue_env = \"{HEADER_SENTINEL}\""),
8177            format!("name = \"x\"\nvalue_file = \"/p\"\nextra = \"{HEADER_SENTINEL}\""),
8178            format!("name = \"x\"\nvalue_file = 12\nextra = \"{HEADER_SENTINEL}\""),
8179        ] {
8180            let error = parse(&bad).expect_err(&format!("{bad:?} must be refused"));
8181            let diagnostic = toml_error_diagnostic("/etc/sozu.toml", &error);
8182            assert!(
8183                !diagnostic.contains(HEADER_SENTINEL),
8184                "the diagnostic must not echo a value: {diagnostic}"
8185            );
8186        }
8187
8188        let accepted = parse(&format!("name = \"x\"\nvalue_file = \"/{REF_SENTINEL}/v\""))
8189            .expect("a name and a value_file parse");
8190        assert_eq!(accepted.headers.len(), 1);
8191        let debug = format!("{accepted:?}");
8192        assert!(
8193            !debug.contains(REF_SENTINEL),
8194            "Debug must redact the reference path: {debug}"
8195        );
8196    }
8197
8198    /// Until the protected activation exists, no `WorkerRequest` may leave a
8199    /// cluster whose header values live in files: the refusal is static, comes
8200    /// before any order is returned, and names no reference. A cluster without
8201    /// headers keeps generating its orders.
8202    #[test]
8203    fn health_check_value_file_cluster_generates_no_worker_request() {
8204        for protocol in ["http", "tcp"] {
8205            let path = format!("/{REF_SENTINEL}/v");
8206            let file = value_file_cluster(protocol, &[("x-token", &path)]);
8207            let cluster = file
8208                .to_cluster_config("guarded", &HashSet::new())
8209                .expect("builds");
8210
8211            let direct = cluster
8212                .generate_requests()
8213                .expect_err("no order may carry a name without its value");
8214            assert!(
8215                matches!(direct, ConfigError::InvalidHealthCheck { .. }),
8216                "{protocol}: {direct:?}"
8217            );
8218
8219            let mut config = Config::default();
8220            config.clusters.insert("guarded".to_owned(), cluster);
8221            let error = config
8222                .generate_config_messages()
8223                .expect_err("the whole generation is refused before any order is returned");
8224            let shown = format!("{error} {error:?}");
8225            assert!(
8226                matches!(error, ConfigError::InvalidHealthCheck { .. })
8227                    && shown.contains("guarded")
8228                    && !shown.contains(REF_SENTINEL),
8229                "{protocol}: {shown}"
8230            );
8231        }
8232
8233        let headerless: FileClusterConfig = toml::from_str(
8234            "protocol = \"http\"\nfrontends = []\nbackends = []\n[health_check]\nuri = \"/\"\n",
8235        )
8236        .expect("parses");
8237        let cluster = headerless
8238            .to_cluster_config("plain", &HashSet::new())
8239            .expect("builds");
8240        assert!(cluster.generate_requests().is_ok());
8241    }
8242
8243    #[test]
8244    fn multiple_listeners_generate_correct_worker_requests() {
8245        let toml_content = r#"
8246            command_socket = "/tmp/sozu_test.sock"
8247            worker_count = 1
8248            activate_listeners = true
8249
8250            [[listeners]]
8251            protocol = "http"
8252            address = "172.16.20.1:80"
8253            expect_proxy = true
8254
8255            [[listeners]]
8256            protocol = "http"
8257            address = "10.22.0.1:80"
8258            expect_proxy = false
8259        "#;
8260
8261        let file_config: FileConfig =
8262            toml::from_str(toml_content).expect("Could not parse TOML config");
8263
8264        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8265            .into_config()
8266            .expect("Could not build config");
8267
8268        let messages = config
8269            .generate_config_messages()
8270            .expect("Could not generate config messages");
8271
8272        let add_listener_count = messages
8273            .iter()
8274            .filter(|m| {
8275                matches!(
8276                    m.content.request_type,
8277                    Some(RequestType::AddHttpListener(_))
8278                )
8279            })
8280            .count();
8281
8282        let activate_listener_count = messages
8283            .iter()
8284            .filter(|m| {
8285                matches!(
8286                    m.content.request_type,
8287                    Some(RequestType::ActivateListener(ActivateListener {
8288                        proxy,
8289                        ..
8290                    })) if proxy == ListenerType::Http as i32
8291                )
8292            })
8293            .count();
8294
8295        assert_eq!(add_listener_count, 2);
8296        assert_eq!(activate_listener_count, 2);
8297    }
8298
8299    #[test]
8300    fn documented_udp_dns_example_loads_and_emits_udp_requests() {
8301        // The DNS example from doc/configure.md ("#### UDP clusters"): a
8302        // `protocol = "udp"` listener + a `protocol = "tcp"` cluster whose
8303        // frontend points at that UDP listener address, with datagram knobs
8304        // under `[clusters.dns.udp]`. This must load without a
8305        // `WrongFrontendProtocol` error and emit an `AddUdpListener` and an
8306        // `AddUdpFrontend` (not `AddTcpFrontend`).
8307        let toml_content = r#"
8308            command_socket = "/tmp/sozu_test.sock"
8309            worker_count = 1
8310            activate_listeners = true
8311
8312            [[listeners]]
8313            protocol = "udp"
8314            address  = "0.0.0.0:53"
8315
8316            [clusters.dns]
8317            protocol       = "tcp"
8318            load_balancing = "HRW"
8319            frontends = [
8320              { address = "0.0.0.0:53" }
8321            ]
8322            backends = [
8323              { address = "10.0.0.10:53" },
8324              { address = "10.0.0.11:53" }
8325            ]
8326
8327            [clusters.dns.udp]
8328            affinity_key        = "SOURCE_IP"
8329            responses           = 1
8330            requests            = 0
8331            send_proxy_protocol = true
8332
8333            [clusters.dns.udp.health]
8334            mode      = "TCP_PROBE"
8335            tcp_port  = 53
8336            rise      = 2
8337            fall      = 3
8338            fail_open = true
8339        "#;
8340
8341        let file_config: FileConfig =
8342            toml::from_str(toml_content).expect("Could not parse documented DNS TOML");
8343
8344        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8345            .into_config()
8346            .expect("documented UDP DNS example must load without WrongFrontendProtocol");
8347
8348        // The UDP listener was registered.
8349        assert_eq!(
8350            config.udp_listeners.len(),
8351            1,
8352            "the protocol=\"udp\" listener must be built"
8353        );
8354
8355        let messages = config
8356            .generate_config_messages()
8357            .expect("Could not generate config messages");
8358
8359        let add_udp_listener_count = messages
8360            .iter()
8361            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpListener(_))))
8362            .count();
8363        assert_eq!(
8364            add_udp_listener_count, 1,
8365            "must emit exactly one AddUdpListener"
8366        );
8367
8368        let add_udp_frontend_count = messages
8369            .iter()
8370            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpFrontend(_))))
8371            .count();
8372        assert_eq!(
8373            add_udp_frontend_count, 1,
8374            "the cluster frontend on the UDP listener must emit AddUdpFrontend"
8375        );
8376
8377        // It must NOT have been emitted as a TCP frontend.
8378        let add_tcp_frontend_count = messages
8379            .iter()
8380            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddTcpFrontend(_))))
8381            .count();
8382        assert_eq!(
8383            add_tcp_frontend_count, 0,
8384            "a UDP-listener-addressed frontend must not be emitted as AddTcpFrontend"
8385        );
8386
8387        // The AddUdpFrontend carries the cluster id and address.
8388        let udp_frontend = messages
8389            .iter()
8390            .find_map(|m| match &m.content.request_type {
8391                Some(RequestType::AddUdpFrontend(f)) => Some(f),
8392                _ => None,
8393            })
8394            .expect("AddUdpFrontend must be present");
8395        assert_eq!(udp_frontend.cluster_id, "dns");
8396        assert_eq!(
8397            SocketAddr::from(udp_frontend.address),
8398            "0.0.0.0:53".parse().unwrap()
8399        );
8400
8401        // The UDP cluster knobs from [clusters.dns.udp] survive onto the
8402        // AddCluster request.
8403        let cluster = messages
8404            .iter()
8405            .find_map(|m| match &m.content.request_type {
8406                Some(RequestType::AddCluster(c)) if c.cluster_id == "dns" => Some(c),
8407                _ => None,
8408            })
8409            .expect("AddCluster for 'dns' must be present");
8410        let udp = cluster
8411            .udp
8412            .as_ref()
8413            .expect("[clusters.dns.udp] block must carry onto the cluster");
8414        assert_eq!(udp.responses, Some(1));
8415    }
8416
8417    /// The `interface` key round-trips from the TOML file into every
8418    /// listener config and its activation, and a listener that omits it keeps
8419    /// `interface = None` — today's behaviour. Two listeners may share an
8420    /// address on different interfaces; the same (address, interface) twice is
8421    /// still a duplicate.
8422    #[test]
8423    fn listener_interface_round_trips_from_the_config_file() {
8424        let toml_content = r#"
8425            command_socket = "/tmp/sozu_test.sock"
8426            worker_count = 1
8427            activate_listeners = true
8428
8429            [[listeners]]
8430            protocol = "http"
8431            address = "0.0.0.0:8080"
8432            interface = "wg0"
8433
8434            [[listeners]]
8435            protocol = "http"
8436            address = "0.0.0.0:8080"
8437            interface = "lo"
8438
8439            [[listeners]]
8440            protocol = "http"
8441            address = "0.0.0.0:8081"
8442
8443            [[listeners]]
8444            protocol = "https"
8445            address = "0.0.0.0:8443"
8446            interface = "wg0"
8447
8448            [[listeners]]
8449            protocol = "tcp"
8450            address = "0.0.0.0:9000"
8451            interface = "wg0"
8452
8453            [[listeners]]
8454            protocol = "udp"
8455            address = "0.0.0.0:5353"
8456            interface = "wg0"
8457        "#;
8458
8459        let file_config: FileConfig =
8460            toml::from_str(toml_content).expect("the interface key must parse");
8461        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8462            .into_config()
8463            .expect("listeners on one address with different interfaces must build");
8464
8465        let http: Vec<(String, Option<&str>)> = config
8466            .http_listeners
8467            .iter()
8468            .map(|l| {
8469                (
8470                    SocketAddr::from(l.address).to_string(),
8471                    l.interface.as_deref(),
8472                )
8473            })
8474            .collect();
8475        assert_eq!(
8476            http,
8477            vec![
8478                ("0.0.0.0:8080".to_owned(), Some("wg0")),
8479                ("0.0.0.0:8080".to_owned(), Some("lo")),
8480                ("0.0.0.0:8081".to_owned(), None),
8481            ]
8482        );
8483        assert_eq!(config.https_listeners[0].interface.as_deref(), Some("wg0"));
8484        assert_eq!(config.tcp_listeners[0].interface.as_deref(), Some("wg0"));
8485        assert_eq!(config.udp_listeners[0].interface.as_deref(), Some("wg0"));
8486
8487        let activations: Vec<Option<String>> = config
8488            .generate_config_messages()
8489            .expect("generate the config messages")
8490            .into_iter()
8491            .filter_map(|request| match request.content.request_type {
8492                Some(RequestType::ActivateListener(activate)) => Some(activate.interface),
8493                _ => None,
8494            })
8495            .collect();
8496        assert_eq!(
8497            activations,
8498            vec![
8499                Some("wg0".to_owned()),
8500                Some("lo".to_owned()),
8501                None,
8502                Some("wg0".to_owned()),
8503                Some("wg0".to_owned()),
8504                Some("wg0".to_owned()),
8505            ]
8506        );
8507
8508        let duplicate = r#"
8509            command_socket = "/tmp/sozu_test.sock"
8510            worker_count = 1
8511
8512            [[listeners]]
8513            protocol = "http"
8514            address = "0.0.0.0:8080"
8515            interface = "wg0"
8516
8517            [[listeners]]
8518            protocol = "http"
8519            address = "0.0.0.0:8080"
8520            interface = "wg0"
8521        "#;
8522        let file_config: FileConfig = toml::from_str(duplicate).expect("parse");
8523        assert!(
8524            ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8525                .into_config()
8526                .is_err(),
8527            "the same (address, interface) twice must be refused"
8528        );
8529    }
8530
8531    /// An interface name the kernel would refuse is reported at config load,
8532    /// naming the listener and the interface.
8533    #[test]
8534    fn invalid_listener_interface_rejected_at_config_load() {
8535        let toml_content = r#"
8536            command_socket = "/tmp/sozu_test.sock"
8537            worker_count = 1
8538
8539            [[listeners]]
8540            protocol = "http"
8541            address = "0.0.0.0:8080"
8542            interface = "wg/0"
8543        "#;
8544        let file_config: FileConfig = toml::from_str(toml_content).expect("parse");
8545        let error = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
8546            .into_config()
8547            .expect_err("an invalid interface name must be refused");
8548        let message = error.to_string();
8549        assert!(message.contains("0.0.0.0:8080"), "{message}");
8550        assert!(message.contains("wg/0"), "{message}");
8551    }
8552
8553    #[test]
8554    fn duplicate_listener_address_rejected() {
8555        let toml_content = r#"
8556            command_socket = "/tmp/sozu_test.sock"
8557            worker_count = 1
8558
8559            [[listeners]]
8560            protocol = "http"
8561            address = "0.0.0.0:80"
8562
8563            [[listeners]]
8564            protocol = "http"
8565            address = "0.0.0.0:80"
8566        "#;
8567
8568        let file_config: FileConfig =
8569            toml::from_str(toml_content).expect("Could not parse TOML config");
8570
8571        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8572
8573        assert!(
8574            result.is_err(),
8575            "Should reject duplicate listener addresses"
8576        );
8577    }
8578
8579    #[test]
8580    fn buffer_size_below_h2_minimum_rejected() {
8581        // Default ALPN ["h2", "http/1.1"] + buffer_size = 8192 must error.
8582        let toml_content = r#"
8583            command_socket = "/tmp/sozu_test.sock"
8584            worker_count = 1
8585            buffer_size = 8192
8586
8587            [[listeners]]
8588            protocol = "https"
8589            address = "127.0.0.1:8443"
8590        "#;
8591        let file_config: FileConfig =
8592            toml::from_str(toml_content).expect("Could not parse TOML config");
8593        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8594        match result {
8595            Err(ConfigError::BufferSizeTooSmallForH2 {
8596                buffer_size: 8192,
8597                minimum: 16_393,
8598                listeners: 1,
8599            }) => {}
8600            other => panic!("expected BufferSizeTooSmallForH2, got {other:?}"),
8601        }
8602    }
8603
8604    #[test]
8605    fn buffer_size_below_h2_minimum_accepted_when_no_h2_listener() {
8606        // Drop "h2" from ALPN — buffer_size = 8192 is now valid.
8607        let toml_content = r#"
8608            command_socket = "/tmp/sozu_test.sock"
8609            worker_count = 1
8610            buffer_size = 8192
8611
8612            [[listeners]]
8613            protocol = "https"
8614            address = "127.0.0.1:8443"
8615            alpn_protocols = ["http/1.1"]
8616        "#;
8617        let file_config: FileConfig =
8618            toml::from_str(toml_content).expect("Could not parse TOML config");
8619        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8620        assert!(
8621            result.is_ok(),
8622            "non-H2 HTTPS listener with sub-16393 buffer should be accepted: {result:?}"
8623        );
8624    }
8625
8626    fn command_socket_mode_of(mode_line: &str) -> Result<Config, ConfigError> {
8627        let toml_content = format!(
8628            r#"
8629            command_socket = "/tmp/sozu_test.sock"
8630            worker_count = 1
8631            {mode_line}
8632        "#
8633        );
8634        let file_config: FileConfig =
8635            toml::from_str(&toml_content).expect("Could not parse TOML config");
8636        ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config()
8637    }
8638
8639    #[test]
8640    fn command_socket_mode_defaults_to_0600() {
8641        let config = command_socket_mode_of("").expect("config without the key must load");
8642        assert_eq!(config.command_socket_mode, 0o600);
8643        assert_eq!(DEFAULT_COMMAND_SOCKET_MODE, 0o600);
8644    }
8645
8646    #[test]
8647    fn command_socket_mode_accepts_octal_strings() {
8648        for (value, expected) in [
8649            ("0660", 0o660),
8650            ("660", 0o660),
8651            ("0600", 0o600),
8652            ("0666", 0o666),
8653            ("777", 0o777),
8654            ("0777", 0o777),
8655            ("600", 0o600),
8656            ("0640", 0o640),
8657        ] {
8658            let config = command_socket_mode_of(&format!("command_socket_mode = \"{value}\""))
8659                .unwrap_or_else(|e| panic!("{value:?} must load: {e}"));
8660            assert_eq!(config.command_socket_mode, expected, "{value:?}");
8661        }
8662    }
8663
8664    #[test]
8665    fn command_socket_mode_rejects_invalid_values() {
8666        for value in [
8667            "", "8", "0668", "abc", "0o660", "0x1b6", "-660", "+660", " 660", "660 ", "1660",
8668            "2770", "4755", "7777", "01000", "00660", "0", "0000", "0066", "0200", "0400", "0466",
8669        ] {
8670            match command_socket_mode_of(&format!("command_socket_mode = \"{value}\"")) {
8671                Err(ConfigError::InvalidCommandSocketMode {
8672                    value: rejected, ..
8673                }) => {
8674                    assert_eq!(rejected, value)
8675                }
8676                other => panic!("{value:?} must be rejected, got {other:?}"),
8677            }
8678        }
8679    }
8680
8681    #[test]
8682    fn command_socket_mode_rejects_a_toml_integer() {
8683        // `660` as a TOML integer is decimal 660 = 0o1224: refuse the type
8684        // rather than guess which base the operator meant. The refusal is a
8685        // TOML deserialization error, raised before `parse_command_socket_mode`.
8686        let dir = tempfile::tempdir().expect("tempdir");
8687        let path = dir.path().join("config.toml");
8688        std::fs::write(
8689            &path,
8690            "command_socket = \"/tmp/sozu_test.sock\"\ncommand_socket_mode = 660\n",
8691        )
8692        .expect("write config");
8693        match FileConfig::load_from_path(path.to_str().expect("utf-8 path")) {
8694            Err(ConfigError::DeserializeToml(message)) => assert!(
8695                message.contains("config.toml") && message.contains(" at bytes "),
8696                "the diagnostic keeps the path and the byte range: {message}"
8697            ),
8698            other => panic!("an integer mode must fail as DeserializeToml, got {other:?}"),
8699        }
8700    }
8701
8702    #[test]
8703    fn config_without_command_socket_mode_deserializes_to_0600() {
8704        // An upgrade snapshot written by a main process that predates the key
8705        // carries a `Config` without it.
8706        let mut encoded = serde_json::to_value(Config::default()).expect("serialize Config");
8707        encoded
8708            .as_object_mut()
8709            .expect("Config serializes to an object")
8710            .remove("command_socket_mode")
8711            .expect("Config serializes command_socket_mode");
8712        let decoded: Config = serde_json::from_value(encoded).expect("deserialize Config");
8713        assert_eq!(decoded.command_socket_mode, 0o600);
8714    }
8715
8716    #[test]
8717    fn command_buffer_size_exceeds_max_rejected() {
8718        // command_buffer_size > max_command_buffer_size must be rejected at load
8719        // (sozu-proxy/sozu#1416): otherwise the real command Channel::new starts its
8720        // front/back buffers larger than the ceiling max_buffer_size is supposed to
8721        // enforce.
8722        let toml_content = r#"
8723            command_socket = "/tmp/sozu_test.sock"
8724            worker_count = 1
8725            command_buffer_size = 2000000
8726            max_command_buffer_size = 1000000
8727        "#;
8728        let file_config: FileConfig =
8729            toml::from_str(toml_content).expect("Could not parse TOML config");
8730        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8731        match result {
8732            Err(ConfigError::CommandBufferSizeExceedsMax {
8733                command_buffer_size: 2_000_000,
8734                max_command_buffer_size: 1_000_000,
8735            }) => {}
8736            other => panic!("expected CommandBufferSizeExceedsMax, got {other:?}"),
8737        }
8738    }
8739
8740    #[test]
8741    fn command_buffer_size_equal_to_max_accepted() {
8742        // The boundary itself (command_buffer_size == max_command_buffer_size) must
8743        // load: `Channel::new` allocates exactly at the ceiling, never above it.
8744        let toml_content = r#"
8745            command_socket = "/tmp/sozu_test.sock"
8746            worker_count = 1
8747            command_buffer_size = 1000000
8748            max_command_buffer_size = 1000000
8749        "#;
8750        let file_config: FileConfig =
8751            toml::from_str(toml_content).expect("Could not parse TOML config");
8752        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8753        assert!(
8754            result.is_ok(),
8755            "command_buffer_size == max_command_buffer_size should be accepted: {result:?}"
8756        );
8757    }
8758
8759    #[test]
8760    fn buffer_size_at_h2_minimum_accepted() {
8761        let toml_content = r#"
8762            command_socket = "/tmp/sozu_test.sock"
8763            worker_count = 1
8764            buffer_size = 16393
8765
8766            [[listeners]]
8767            protocol = "https"
8768            address = "127.0.0.1:8443"
8769        "#;
8770        let file_config: FileConfig =
8771            toml::from_str(toml_content).expect("Could not parse TOML config");
8772        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
8773        assert!(
8774            result.is_ok(),
8775            "buffer_size at the H2 minimum should be accepted: {result:?}"
8776        );
8777    }
8778
8779    #[test]
8780    fn alpn_protocols_default() {
8781        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8782        let config = builder.to_tls(None).expect("to_tls should succeed");
8783        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
8784    }
8785
8786    #[test]
8787    fn alpn_protocols_custom() {
8788        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8789        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned()]));
8790        let config = builder.to_tls(None).expect("to_tls should succeed");
8791        assert_eq!(config.alpn_protocols, vec!["http/1.1"]);
8792    }
8793
8794    #[test]
8795    fn alpn_protocols_invalid_rejected() {
8796        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8797        builder.with_alpn_protocols(Some(vec!["h3".to_owned()]));
8798        let result = builder.to_tls(None);
8799        assert!(result.is_err());
8800        let err = result.unwrap_err();
8801        assert!(
8802            err.to_string().contains("h3"),
8803            "error should mention the invalid protocol: {err}"
8804        );
8805    }
8806
8807    #[test]
8808    fn alpn_protocols_empty_uses_default() {
8809        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8810        builder.with_alpn_protocols(Some(vec![]));
8811        let config = builder.to_tls(None).expect("to_tls should succeed");
8812        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
8813    }
8814
8815    #[test]
8816    fn alpn_protocols_deduplicated() {
8817        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8818        builder.with_alpn_protocols(Some(vec![
8819            "h2".to_owned(),
8820            "h2".to_owned(),
8821            "http/1.1".to_owned(),
8822        ]));
8823        let config = builder.to_tls(None).expect("to_tls should succeed");
8824        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
8825    }
8826
8827    #[test]
8828    fn alpn_protocols_order_preserved() {
8829        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
8830        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned(), "h2".to_owned()]));
8831        let config = builder.to_tls(None).expect("to_tls should succeed");
8832        assert_eq!(config.alpn_protocols, vec!["http/1.1", "h2"]);
8833    }
8834
8835    /// CRLF or NUL in a `[[clusters.<id>.frontends.headers]]` value
8836    /// would let an operator-supplied config splice arbitrary
8837    /// header / request lines into the H1 wire on the backend side
8838    /// (CWE-113). The H2 emission path filters at runtime; we reject
8839    /// at config-load time as a defense in depth.
8840    #[test]
8841    fn parse_header_edit_rejects_crlf_in_value() {
8842        let entry = HeaderEditConfig {
8843            position: "request".to_owned(),
8844            key: "X-Test".to_owned(),
8845            value: "value\r\nEvil-Header: stolen".to_owned(),
8846        };
8847        let err = parse_header_edit(0, &entry).expect_err("CRLF in value must be rejected");
8848        match err {
8849            ConfigError::InvalidHeaderBytes { index, field } => {
8850                assert_eq!(index, 0);
8851                assert_eq!(field, "value");
8852            }
8853            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
8854        }
8855    }
8856
8857    #[test]
8858    fn parse_header_edit_rejects_lf_in_key() {
8859        let entry = HeaderEditConfig {
8860            position: "response".to_owned(),
8861            key: "X-\nTest".to_owned(),
8862            value: "ok".to_owned(),
8863        };
8864        let err = parse_header_edit(2, &entry).expect_err("LF in key must be rejected");
8865        match err {
8866            ConfigError::InvalidHeaderBytes { index, field } => {
8867                assert_eq!(index, 2);
8868                assert_eq!(field, "key");
8869            }
8870            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
8871        }
8872    }
8873
8874    #[test]
8875    fn parse_header_edit_rejects_nul() {
8876        let entry = HeaderEditConfig {
8877            position: "both".to_owned(),
8878            key: "X-Test".to_owned(),
8879            value: "with\0nul".to_owned(),
8880        };
8881        assert!(matches!(
8882            parse_header_edit(0, &entry),
8883            Err(ConfigError::InvalidHeaderBytes { .. })
8884        ));
8885    }
8886
8887    /// Horizontal tab `\t` (0x09) is permitted in field values per
8888    /// RFC 9110 §5.5 (folded-header obs-fold parts). The value-side
8889    /// validator must NOT reject it — otherwise legitimate operator
8890    /// configs (e.g. `Authorization: Basic\tCREDENTIALS`) become
8891    /// unusable. The key-side validator IS stricter (token grammar).
8892    #[test]
8893    fn parse_header_edit_accepts_tab_in_value() {
8894        let entry = HeaderEditConfig {
8895            position: "request".to_owned(),
8896            key: "X-Test".to_owned(),
8897            value: "with\ttab".to_owned(),
8898        };
8899        let header = parse_header_edit(0, &entry).expect("tab in value must be accepted");
8900        assert_eq!(header.val, "with\ttab");
8901    }
8902
8903    /// Header NAMES follow `token` grammar per RFC 9110 §5.1. HTAB and
8904    /// SP are NOT tchar; the key-side validator must reject them even
8905    /// though the value-side validator permits HTAB. Without this,
8906    /// an operator entry like `key = "Host\t"` would emit `Host\t: …`
8907    /// on the H1 wire and produce an invalid (but parser-tolerant)
8908    /// header line that some backends silently accept as `Host:`.
8909    #[test]
8910    fn parse_header_edit_rejects_tab_in_key() {
8911        let entry = HeaderEditConfig {
8912            position: "request".to_owned(),
8913            key: "Host\t".to_owned(),
8914            value: "ok".to_owned(),
8915        };
8916        let err = parse_header_edit(0, &entry).expect_err("HTAB in key must be rejected");
8917        match err {
8918            ConfigError::InvalidHeaderBytes { field, .. } => assert_eq!(field, "key"),
8919            other => panic!("expected InvalidHeaderBytes{{field=\"key\"}}, got {other:?}"),
8920        }
8921    }
8922
8923    #[test]
8924    fn parse_header_edit_rejects_space_in_key() {
8925        let entry = HeaderEditConfig {
8926            position: "request".to_owned(),
8927            key: "X Test".to_owned(),
8928            value: "ok".to_owned(),
8929        };
8930        let err = parse_header_edit(0, &entry).expect_err("SP in key must be rejected");
8931        assert!(matches!(err, ConfigError::InvalidHeaderBytes { .. }));
8932    }
8933
8934    #[test]
8935    fn parse_header_edit_rejects_empty_key() {
8936        let entry = HeaderEditConfig {
8937            position: "request".to_owned(),
8938            key: String::new(),
8939            value: "ok".to_owned(),
8940        };
8941        let err = parse_header_edit(0, &entry).expect_err("empty key must be rejected");
8942        assert!(matches!(
8943            err,
8944            ConfigError::InvalidHeaderBytes { field: "key", .. }
8945        ));
8946    }
8947
8948    #[test]
8949    fn parse_header_edit_accepts_clean_value() {
8950        let entry = HeaderEditConfig {
8951            position: "request".to_owned(),
8952            key: "X-Tenant".to_owned(),
8953            value: "alpha".to_owned(),
8954        };
8955        let header = parse_header_edit(0, &entry).expect("clean value must be accepted");
8956        assert_eq!(header.key, "X-Tenant");
8957        assert_eq!(header.val, "alpha");
8958    }
8959
8960    /// A bare string with no scheme prefix is the inline literal body.
8961    /// This is the common case — short canned responses inline in TOML
8962    /// or a `--answer` flag, no disk I/O.
8963    #[test]
8964    fn resolve_answer_source_bare_string_is_literal() {
8965        let body = resolve_answer_source("HTTP/1.1 503 Service Unavailable\r\n\r\nbusy")
8966            .expect("bare-string source must resolve");
8967        assert_eq!(body, "HTTP/1.1 503 Service Unavailable\r\n\r\nbusy");
8968    }
8969
8970    #[test]
8971    fn resolve_answer_source_empty_string_is_legitimate() {
8972        let body = resolve_answer_source("").expect("empty source must resolve");
8973        assert_eq!(body, "");
8974    }
8975
8976    /// `file://` opts into reading the path off disk. A non-existent
8977    /// path bubbles up as `ConfigError::FileOpen` so the operator gets
8978    /// the same diagnostics as the existing per-status `answer_NNN`
8979    /// flow.
8980    #[test]
8981    fn resolve_answer_source_file_scheme_missing_file_errors() {
8982        let err = resolve_answer_source("file:///nonexistent/sozu-test/never.http")
8983            .expect_err("missing path must error");
8984        assert!(matches!(err, ConfigError::FileOpen { .. }));
8985    }
8986
8987    /// `file://` strips the scheme; an empty path after the scheme is
8988    /// rejected (empty path on filesystem read).
8989    #[test]
8990    fn resolve_answer_source_file_scheme_empty_path_errors() {
8991        let err = resolve_answer_source("file://").expect_err("empty path must error");
8992        assert!(matches!(err, ConfigError::FileOpen { .. }));
8993    }
8994
8995    // ── TCP SNI/ALPN frontends (sozu-proxy/sozu#1279) ───────────────────────
8996
8997    /// A legacy-style TCP frontend TOML (no `hostname`/`alpn` keys at all)
8998    /// must still parse, and the built frontend/listener must carry the
8999    /// same values as before this feature existed: `sni = None`,
9000    /// `alpn = []`, and the proto default SNI-preread knobs (unused since
9001    /// no SNI frontend targets the listener).
9002    #[test]
9003    fn legacy_tcp_frontend_toml_without_sni_alpn_parses_unchanged() {
9004        let toml_content = r#"
9005            command_socket = "/tmp/sozu_test.sock"
9006            worker_count = 1
9007
9008            [[listeners]]
9009            protocol = "tcp"
9010            address  = "127.0.0.1:9000"
9011
9012            [clusters.legacy]
9013            protocol       = "tcp"
9014            load_balancing = "ROUND_ROBIN"
9015            frontends = [
9016              { address = "127.0.0.1:9000" }
9017            ]
9018            backends = [
9019              { address = "10.0.0.1:9000" }
9020            ]
9021        "#;
9022        let file_config: FileConfig =
9023            toml::from_str(toml_content).expect("Could not parse legacy TCP TOML");
9024        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9025            .into_config()
9026            .expect("legacy TCP config without sni/alpn must load unchanged");
9027
9028        assert_eq!(config.tcp_listeners.len(), 1);
9029        let listener = &config.tcp_listeners[0];
9030        assert_eq!(
9031            listener.sni_preread_timeout,
9032            Some(DEFAULT_SNI_PREREAD_TIMEOUT),
9033            "proto default sni_preread_timeout must be populated even though unused"
9034        );
9035        assert_eq!(
9036            listener.sni_preread_max_bytes,
9037            Some(DEFAULT_SNI_PREREAD_MAX_BYTES),
9038            "proto default sni_preread_max_bytes must be populated even though unused"
9039        );
9040
9041        let messages = config
9042            .generate_config_messages()
9043            .expect("Could not generate config messages");
9044        let tcp_frontend = messages
9045            .iter()
9046            .find_map(|m| match &m.content.request_type {
9047                Some(RequestType::AddTcpFrontend(f)) => Some(f),
9048                _ => None,
9049            })
9050            .expect("AddTcpFrontend must be present");
9051        assert_eq!(tcp_frontend.sni, None, "legacy frontend must carry no sni");
9052        assert!(
9053            tcp_frontend.alpn.is_empty(),
9054            "legacy frontend must carry no alpn"
9055        );
9056    }
9057
9058    /// sozu#633: the catch-all frontend `doc/configure.md` documents —
9059    /// `hostname = "*"` with `position = "POST"` — loads from TOML and
9060    /// reaches the worker as a `Post` rule on `*`. The position is an enum
9061    /// spelled in upper case: `"Post"` is refused with the serde message the
9062    /// documentation quotes, rather than silently falling back to `TREE`.
9063    ///
9064    /// To SEE THIS RED: in `command/build.rs`, drop the
9065    /// `#[serde(rename_all = "SCREAMING_SNAKE_CASE")]` enum attribute. The
9066    /// first `toml::from_str` then fails with
9067    /// "unknown variant `POST`, expected one of `Pre`, `Post`, `Tree`".
9068    #[test]
9069    fn a_catch_all_post_frontend_loads_from_toml_and_rejects_a_mixed_case_position() {
9070        let catch_all = |position: &str| {
9071            format!(
9072                r#"
9073            command_socket = "/tmp/sozu_test.sock"
9074            worker_count = 1
9075
9076            [[listeners]]
9077            protocol = "http"
9078            address  = "127.0.0.1:8080"
9079
9080            [clusters.fallback]
9081            protocol = "http"
9082            frontends = [
9083              {{ address = "127.0.0.1:8080", hostname = "*", position = "{position}" }}
9084            ]
9085            backends = [
9086              {{ address = "10.0.0.1:8000" }}
9087            ]
9088        "#
9089            )
9090        };
9091
9092        let file_config: FileConfig = toml::from_str(&catch_all("POST"))
9093            .expect("an upper-case `position = \"POST\"` must parse");
9094        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9095            .into_config()
9096            .expect("a catch-all POST frontend must load");
9097        let messages = config
9098            .generate_config_messages()
9099            .expect("Could not generate config messages");
9100        let frontend = messages
9101            .iter()
9102            .find_map(|m| match &m.content.request_type {
9103                Some(RequestType::AddHttpFrontend(f)) => Some(f),
9104                _ => None,
9105            })
9106            .expect("AddHttpFrontend must be present");
9107        assert_eq!(frontend.hostname, "*");
9108        assert_eq!(frontend.position, RulePosition::Post as i32);
9109
9110        let error = toml::from_str::<FileConfig>(&catch_all("Post"))
9111            .expect_err("a mixed-case `position = \"Post\"` must be refused")
9112            .to_string();
9113        assert!(
9114            error.contains("unknown variant `Post`, expected one of `PRE`, `POST`, `TREE`"),
9115            "unexpected error: {error}",
9116        );
9117    }
9118
9119    /// sozu#322: `forwarded_headers` is a per-listener enum spelled
9120    /// `both`, `x_forwarded`, `rfc7239` or `none`. Each value reaches the
9121    /// wire config of an HTTP and an HTTPS listener, an absent key is
9122    /// `both` (the historical behaviour), and any other spelling is refused
9123    /// by serde instead of silently falling back to the default.
9124    #[test]
9125    fn forwarded_headers_parses_every_mode_defaults_to_both_and_rejects_others() {
9126        let listeners = |value: Option<&str>| {
9127            let line = value
9128                .map(|value| format!("forwarded_headers = \"{value}\""))
9129                .unwrap_or_default();
9130            format!(
9131                r#"
9132            command_socket = "/tmp/sozu_test.sock"
9133            worker_count = 1
9134
9135            [[listeners]]
9136            protocol = "http"
9137            address  = "127.0.0.1:8080"
9138            {line}
9139
9140            [[listeners]]
9141            protocol = "https"
9142            address  = "127.0.0.1:8443"
9143            {line}
9144            "#
9145            )
9146        };
9147        let load = |value: Option<&str>| {
9148            let file_config: FileConfig =
9149                toml::from_str(&listeners(value)).expect("the listeners must parse");
9150            let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9151                .into_config()
9152                .expect("the listeners must load");
9153            (
9154                config.http_listeners[0].forwarded_headers,
9155                config.https_listeners[0].forwarded_headers,
9156            )
9157        };
9158
9159        for (spelling, expected) in [
9160            (None, ForwardedHeaders::Both),
9161            (Some("both"), ForwardedHeaders::Both),
9162            (Some("x_forwarded"), ForwardedHeaders::XForwarded),
9163            (Some("rfc7239"), ForwardedHeaders::Rfc7239),
9164            (Some("none"), ForwardedHeaders::None),
9165        ] {
9166            let expected = Some(expected as i32);
9167            assert_eq!(load(spelling), (expected, expected), "{spelling:?}");
9168        }
9169
9170        for invalid in ["RFC7239", "x-forwarded", "forwarded", ""] {
9171            let error = toml::from_str::<FileConfig>(&listeners(Some(invalid)))
9172                .expect_err("an unknown forwarded_headers value must be refused")
9173                .to_string();
9174            assert!(
9175                error.contains(&format!(
9176                    "unknown variant `{invalid}`, expected one of `both`, `x_forwarded`, `rfc7239`, `none`"
9177                )),
9178                "{invalid:?}: unexpected error: {error}",
9179            );
9180        }
9181    }
9182
9183    /// `hostname` on a TCP frontend maps to the wire `sni` field, exact
9184    /// hostnames and a single leading `*.` wildcard are both accepted.
9185    #[test]
9186    fn tcp_frontend_hostname_maps_to_sni_exact_and_wildcard() {
9187        let toml_content = r#"
9188            command_socket = "/tmp/sozu_test.sock"
9189            worker_count = 1
9190
9191            [[listeners]]
9192            protocol = "tcp"
9193            address  = "127.0.0.1:9010"
9194
9195            [clusters.exact]
9196            protocol       = "tcp"
9197            load_balancing = "ROUND_ROBIN"
9198            frontends = [
9199              { address = "127.0.0.1:9010", hostname = "example.com", alpn = ["h2"] }
9200            ]
9201            backends = [ { address = "10.0.0.1:9010" } ]
9202
9203            [clusters.wildcard]
9204            protocol       = "tcp"
9205            load_balancing = "ROUND_ROBIN"
9206            frontends = [
9207              { address = "127.0.0.1:9010", hostname = "*.example.com" }
9208            ]
9209            backends = [ { address = "10.0.0.2:9010" } ]
9210        "#;
9211        let file_config: FileConfig =
9212            toml::from_str(toml_content).expect("Could not parse TOML config");
9213        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9214            .into_config()
9215            .expect("exact + wildcard SNI frontends on distinct sni must load");
9216
9217        let messages = config
9218            .generate_config_messages()
9219            .expect("Could not generate config messages");
9220        let mut frontends: Vec<_> = messages
9221            .iter()
9222            .filter_map(|m| match &m.content.request_type {
9223                Some(RequestType::AddTcpFrontend(f)) => Some(f.clone()),
9224                _ => None,
9225            })
9226            .collect();
9227        frontends.sort_by(|a, b| a.cluster_id.cmp(&b.cluster_id));
9228
9229        assert_eq!(frontends.len(), 2);
9230        assert_eq!(frontends[0].sni, Some("example.com".to_string()));
9231        assert_eq!(frontends[0].alpn, vec!["h2".to_string()]);
9232        assert_eq!(frontends[1].sni, Some("*.example.com".to_string()));
9233        assert!(frontends[1].alpn.is_empty());
9234    }
9235
9236    /// (a) An SNI pattern with more than one wildcard label, an embedded
9237    /// `*`, an empty label, or any `/` (a leftmost `/.../` label would be
9238    /// inserted into the `pattern_trie` route table as a REGEX segment,
9239    /// silently widening routing) is rejected at config-load.
9240    #[test]
9241    fn tcp_frontend_invalid_sni_pattern_rejected() {
9242        for invalid in [
9243            "*.*.example.com",
9244            "foo.*.com",
9245            "*",
9246            "example..com",
9247            "",
9248            "/[a-z]+/.example.com",
9249            "foo/bar.example.com",
9250        ] {
9251            let frontend = FileClusterFrontendConfig {
9252                address: "127.0.0.1:8080".parse().unwrap(),
9253                hostname: Some(invalid.to_string()),
9254                alpn: vec![],
9255                path: None,
9256                path_type: None,
9257                method: None,
9258                certificate: None,
9259                key: None,
9260                certificate_chain: None,
9261                tls_versions: vec![],
9262                position: RulePosition::Tree,
9263                tags: None,
9264                redirect: None,
9265                redirect_scheme: None,
9266                redirect_template: None,
9267                rewrite_host: None,
9268                rewrite_path: None,
9269                rewrite_port: None,
9270                required_auth: None,
9271                headers: None,
9272                hsts: None,
9273                header_predicates: None,
9274            };
9275            match frontend.to_tcp_front() {
9276                Err(ConfigError::InvalidSniPattern { sni }) => assert_eq!(sni, invalid),
9277                other => panic!("expected InvalidSniPattern for {invalid:?}, got {other:?}"),
9278            }
9279        }
9280    }
9281
9282    /// (a) A non-ASCII SNI pattern is rejected loudly: on-wire SNI is
9283    /// always an ASCII A-label (RFC 6066 / IDNA), so a Unicode U-label in
9284    /// the config would load fine but never match a ClientHello — a
9285    /// silent routing failure. The error names the punycode form the
9286    /// operator must write instead.
9287    #[test]
9288    fn tcp_frontend_non_ascii_sni_pattern_rejected() {
9289        for non_ascii in ["münchen.example", "*.bücher.example", "日本.example"] {
9290            let frontend = FileClusterFrontendConfig {
9291                address: "127.0.0.1:8080".parse().unwrap(),
9292                hostname: Some(non_ascii.to_string()),
9293                alpn: vec![],
9294                path: None,
9295                path_type: None,
9296                method: None,
9297                certificate: None,
9298                key: None,
9299                certificate_chain: None,
9300                tls_versions: vec![],
9301                position: RulePosition::Tree,
9302                tags: None,
9303                redirect: None,
9304                redirect_scheme: None,
9305                redirect_template: None,
9306                rewrite_host: None,
9307                rewrite_path: None,
9308                rewrite_port: None,
9309                required_auth: None,
9310                headers: None,
9311                hsts: None,
9312                header_predicates: None,
9313            };
9314            match frontend.to_tcp_front() {
9315                Err(ConfigError::NonAsciiSniPattern { sni }) => assert_eq!(sni, non_ascii),
9316                other => panic!("expected NonAsciiSniPattern for {non_ascii:?}, got {other:?}"),
9317            }
9318        }
9319    }
9320
9321    /// The punycode A-label form of an internationalized hostname — what
9322    /// the NonAsciiSniPattern error tells the operator to write — is
9323    /// accepted, both exact and wildcarded, and case-normalized like any
9324    /// other ASCII pattern.
9325    #[test]
9326    fn tcp_frontend_punycode_sni_pattern_accepted() {
9327        assert_eq!(
9328            validate_sni_pattern("xn--mnchen-3ya.example").expect("A-label must be accepted"),
9329            "xn--mnchen-3ya.example"
9330        );
9331        assert_eq!(
9332            validate_sni_pattern("*.xn--bcher-kva.example")
9333                .expect("wildcarded A-label must be accepted"),
9334            "*.xn--bcher-kva.example"
9335        );
9336        assert_eq!(
9337            validate_sni_pattern("XN--MNCHEN-3YA.Example")
9338                .expect("mixed-case A-label must be accepted"),
9339            "xn--mnchen-3ya.example",
9340            "A-label patterns are ASCII-lowercased like any other pattern"
9341        );
9342    }
9343
9344    /// `alpn` is a TCP-only concept; setting it on an HTTP frontend is
9345    /// rejected rather than silently ignored.
9346    #[test]
9347    fn alpn_rejected_on_http_frontend() {
9348        let frontend = FileClusterFrontendConfig {
9349            address: "127.0.0.1:8080".parse().unwrap(),
9350            hostname: Some("example.com".to_owned()),
9351            alpn: vec!["h2".to_string()],
9352            path: None,
9353            path_type: None,
9354            method: None,
9355            certificate: None,
9356            key: None,
9357            certificate_chain: None,
9358            tls_versions: vec![],
9359            position: RulePosition::Tree,
9360            tags: None,
9361            redirect: None,
9362            redirect_scheme: None,
9363            redirect_template: None,
9364            rewrite_host: None,
9365            rewrite_path: None,
9366            rewrite_port: None,
9367            required_auth: None,
9368            headers: None,
9369            hsts: None,
9370            header_predicates: None,
9371        };
9372        match frontend.to_http_front("api") {
9373            Err(ConfigError::InvalidFrontendConfig(field)) => assert_eq!(field, "alpn"),
9374            other => panic!("expected InvalidFrontendConfig(\"alpn\"), got {other:?}"),
9375        }
9376    }
9377
9378    /// `header_predicates` is an HTTP-only guard; a TCP frontend that sets it
9379    /// is rejected instead of silently routing unconditionally.
9380    #[test]
9381    fn header_predicates_rejected_on_tcp_frontend() {
9382        let frontend = FileClusterFrontendConfig {
9383            address: "127.0.0.1:8080".parse().unwrap(),
9384            hostname: None,
9385            alpn: vec![],
9386            path: None,
9387            path_type: None,
9388            method: None,
9389            certificate: None,
9390            key: None,
9391            certificate_chain: None,
9392            tls_versions: vec![],
9393            position: RulePosition::Tree,
9394            tags: None,
9395            redirect: None,
9396            redirect_scheme: None,
9397            redirect_template: None,
9398            rewrite_host: None,
9399            rewrite_path: None,
9400            rewrite_port: None,
9401            required_auth: None,
9402            headers: None,
9403            hsts: None,
9404            header_predicates: Some(vec![]),
9405        };
9406        match frontend.to_tcp_front() {
9407            Err(ConfigError::InvalidFrontendConfig(field)) => {
9408                assert_eq!(field, "header_predicates")
9409            }
9410            other => panic!("expected InvalidFrontendConfig(\"header_predicates\"), got {other:?}"),
9411        }
9412    }
9413
9414    /// A TCP frontend that sets `alpn` but leaves `hostname` (the wire
9415    /// `sni` field) unset is a config error: an ALPN matcher only ever gets
9416    /// consulted from within the SNI-scoped preread route table, so a
9417    /// no-SNI frontend would install the raw catch-all path and silently
9418    /// never enforce the configured protocol list.
9419    #[test]
9420    fn tcp_frontend_alpn_without_sni_rejected() {
9421        let toml_content = r#"
9422            command_socket = "/tmp/sozu_test.sock"
9423            worker_count = 1
9424
9425            [[listeners]]
9426            protocol = "tcp"
9427            address  = "127.0.0.1:9019"
9428
9429            [clusters.a]
9430            protocol       = "tcp"
9431            load_balancing = "ROUND_ROBIN"
9432            frontends = [
9433              { address = "127.0.0.1:9019", alpn = ["h2"] }
9434            ]
9435            backends = [ { address = "10.0.0.1:9019" } ]
9436        "#;
9437        let file_config: FileConfig =
9438            toml::from_str(toml_content).expect("Could not parse TOML config");
9439        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9440        match result {
9441            Err(ConfigError::AlpnWithoutSni { address }) => {
9442                assert_eq!(address.to_string(), "127.0.0.1:9019");
9443            }
9444            other => panic!("expected AlpnWithoutSni, got {other:?}"),
9445        }
9446    }
9447
9448    /// (b) Two TCP frontends on the same (address, sni) advertising an
9449    /// overlapping ALPN protocol is a config error — routing on that
9450    /// listener would otherwise depend on iteration order.
9451    #[test]
9452    fn tcp_frontend_alpn_overlap_rejected() {
9453        let toml_content = r#"
9454            command_socket = "/tmp/sozu_test.sock"
9455            worker_count = 1
9456
9457            [[listeners]]
9458            protocol = "tcp"
9459            address  = "127.0.0.1:9020"
9460
9461            [clusters.a]
9462            protocol       = "tcp"
9463            load_balancing = "ROUND_ROBIN"
9464            frontends = [
9465              { address = "127.0.0.1:9020", hostname = "example.com", alpn = ["h2"] }
9466            ]
9467            backends = [ { address = "10.0.0.1:9020" } ]
9468
9469            [clusters.b]
9470            protocol       = "tcp"
9471            load_balancing = "ROUND_ROBIN"
9472            frontends = [
9473              { address = "127.0.0.1:9020", hostname = "example.com", alpn = ["h2", "http/1.1"] }
9474            ]
9475            backends = [ { address = "10.0.0.2:9020" } ]
9476        "#;
9477        let file_config: FileConfig =
9478            toml::from_str(toml_content).expect("Could not parse TOML config");
9479        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9480        match result {
9481            Err(ConfigError::TcpFrontendAlpnOverlap { protocol, .. }) => {
9482                assert_eq!(protocol, "h2");
9483            }
9484            other => panic!("expected TcpFrontendAlpnOverlap, got {other:?}"),
9485        }
9486    }
9487
9488    /// (b) At most one TCP frontend per (address, sni) may leave `alpn`
9489    /// empty (the catch-all match); a second is as ambiguous as an
9490    /// overlapping explicit protocol.
9491    #[test]
9492    fn tcp_frontend_multiple_alpn_catch_all_rejected() {
9493        let toml_content = r#"
9494            command_socket = "/tmp/sozu_test.sock"
9495            worker_count = 1
9496
9497            [[listeners]]
9498            protocol = "tcp"
9499            address  = "127.0.0.1:9021"
9500
9501            [clusters.a]
9502            protocol       = "tcp"
9503            load_balancing = "ROUND_ROBIN"
9504            frontends = [
9505              { address = "127.0.0.1:9021", hostname = "example.com" }
9506            ]
9507            backends = [ { address = "10.0.0.1:9021" } ]
9508
9509            [clusters.b]
9510            protocol       = "tcp"
9511            load_balancing = "ROUND_ROBIN"
9512            frontends = [
9513              { address = "127.0.0.1:9021", hostname = "example.com" }
9514            ]
9515            backends = [ { address = "10.0.0.2:9021" } ]
9516        "#;
9517        let file_config: FileConfig =
9518            toml::from_str(toml_content).expect("Could not parse TOML config");
9519        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9520        assert!(
9521            matches!(
9522                result,
9523                Err(ConfigError::TcpFrontendMultipleAlpnCatchAll { .. })
9524            ),
9525            "expected TcpFrontendMultipleAlpnCatchAll, got {result:?}"
9526        );
9527    }
9528
9529    /// (c) A listener targeted by both a no-SNI frontend and an SNI-scoped
9530    /// frontend is a config error: an SNI-enabled listener must not also
9531    /// carry a raw-TCP fallback.
9532    #[test]
9533    fn tcp_listener_mixes_sni_and_no_sni_rejected() {
9534        let toml_content = r#"
9535            command_socket = "/tmp/sozu_test.sock"
9536            worker_count = 1
9537
9538            [[listeners]]
9539            protocol = "tcp"
9540            address  = "127.0.0.1:9022"
9541
9542            [clusters.a]
9543            protocol       = "tcp"
9544            load_balancing = "ROUND_ROBIN"
9545            frontends = [
9546              { address = "127.0.0.1:9022", hostname = "example.com" }
9547            ]
9548            backends = [ { address = "10.0.0.1:9022" } ]
9549
9550            [clusters.b]
9551            protocol       = "tcp"
9552            load_balancing = "ROUND_ROBIN"
9553            frontends = [
9554              { address = "127.0.0.1:9022" }
9555            ]
9556            backends = [ { address = "10.0.0.2:9022" } ]
9557        "#;
9558        let file_config: FileConfig =
9559            toml::from_str(toml_content).expect("Could not parse TOML config");
9560        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9561        assert!(
9562            matches!(result, Err(ConfigError::TcpListenerMixesSniAndNoSni { .. })),
9563            "expected TcpListenerMixesSniAndNoSni, got {result:?}"
9564        );
9565    }
9566
9567    /// (d) `sni_preread_timeout` (proto default: 5s) exceeding the
9568    /// listener's `front_timeout` is rejected — but only when an SNI
9569    /// frontend actually targets that listener, so legacy TCP-only
9570    /// configs with a low front_timeout keep loading unchanged.
9571    #[test]
9572    fn sni_preread_timeout_exceeding_front_timeout_rejected() {
9573        let toml_content = r#"
9574            command_socket = "/tmp/sozu_test.sock"
9575            worker_count = 1
9576
9577            [[listeners]]
9578            protocol      = "tcp"
9579            address       = "127.0.0.1:9030"
9580            front_timeout = 2
9581
9582            [clusters.a]
9583            protocol       = "tcp"
9584            load_balancing = "ROUND_ROBIN"
9585            frontends = [
9586              { address = "127.0.0.1:9030", hostname = "example.com" }
9587            ]
9588            backends = [ { address = "10.0.0.1:9030" } ]
9589        "#;
9590        let file_config: FileConfig =
9591            toml::from_str(toml_content).expect("Could not parse TOML config");
9592        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9593        match result {
9594            Err(ConfigError::SniPrereadTimeoutExceedsFrontTimeout {
9595                sni_preread_timeout: 5,
9596                front_timeout: 2,
9597                ..
9598            }) => {}
9599            other => panic!("expected SniPrereadTimeoutExceedsFrontTimeout, got {other:?}"),
9600        }
9601    }
9602
9603    /// (e) `sni_preread_max_bytes` (proto default: 16384) exceeding the
9604    /// global `buffer_size` is rejected — again only when an SNI frontend
9605    /// targets the listener.
9606    #[test]
9607    fn sni_preread_max_bytes_exceeding_buffer_size_rejected() {
9608        let toml_content = r#"
9609            command_socket = "/tmp/sozu_test.sock"
9610            worker_count = 1
9611            buffer_size = 8192
9612
9613            [[listeners]]
9614            protocol = "tcp"
9615            address  = "127.0.0.1:9031"
9616
9617            [clusters.a]
9618            protocol       = "tcp"
9619            load_balancing = "ROUND_ROBIN"
9620            frontends = [
9621              { address = "127.0.0.1:9031", hostname = "example.com" }
9622            ]
9623            backends = [ { address = "10.0.0.1:9031" } ]
9624        "#;
9625        let file_config: FileConfig =
9626            toml::from_str(toml_content).expect("Could not parse TOML config");
9627        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9628        match result {
9629            Err(ConfigError::SniPrereadMaxBytesExceedsBufferSize {
9630                sni_preread_max_bytes: 16384,
9631                buffer_size: 8192,
9632                ..
9633            }) => {}
9634            other => panic!("expected SniPrereadMaxBytesExceedsBufferSize, got {other:?}"),
9635        }
9636    }
9637
9638    /// `sni_preread_max_bytes = 0` on an SNI-enabled listener is rejected:
9639    /// the preread shell would issue zero-length reads that never make
9640    /// progress, spinning until the event-loop iteration guard trips
9641    /// instead of ever reaching a routing decision.
9642    #[test]
9643    fn sni_preread_max_bytes_zero_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:9033"
9651            sni_preread_max_bytes = 0
9652
9653            [clusters.a]
9654            protocol       = "tcp"
9655            load_balancing = "ROUND_ROBIN"
9656            frontends = [
9657              { address = "127.0.0.1:9033", hostname = "example.com" }
9658            ]
9659            backends = [ { address = "10.0.0.1:9033" } ]
9660        "#;
9661        let file_config: FileConfig =
9662            toml::from_str(toml_content).expect("Could not parse TOML config");
9663        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9664        match result {
9665            Err(ConfigError::SniPrereadMaxBytesTooSmall {
9666                sni_preread_max_bytes: 0,
9667                minimum: 5,
9668                ..
9669            }) => {}
9670            other => panic!("expected SniPrereadMaxBytesTooSmall, got {other:?}"),
9671        }
9672    }
9673
9674    /// The floor itself (`MIN_SNI_PREREAD_MAX_BYTES` = 5 bytes) must load
9675    /// successfully — only values strictly below it are rejected.
9676    #[test]
9677    fn sni_preread_max_bytes_at_the_floor_loads() {
9678        let toml_content = r#"
9679            command_socket = "/tmp/sozu_test.sock"
9680            worker_count = 1
9681
9682            [[listeners]]
9683            protocol             = "tcp"
9684            address              = "127.0.0.1:9034"
9685            sni_preread_max_bytes = 5
9686
9687            [clusters.a]
9688            protocol       = "tcp"
9689            load_balancing = "ROUND_ROBIN"
9690            frontends = [
9691              { address = "127.0.0.1:9034", hostname = "example.com" }
9692            ]
9693            backends = [ { address = "10.0.0.1:9034" } ]
9694        "#;
9695        let file_config: FileConfig =
9696            toml::from_str(toml_content).expect("Could not parse TOML config");
9697        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9698        assert!(
9699            result.is_ok(),
9700            "sni_preread_max_bytes at the exact floor must load: {result:?}"
9701        );
9702    }
9703
9704    /// Gating proof for (d)/(e): a TCP listener with a low front_timeout
9705    /// and small buffer_size, but *no* SNI frontend, must load unchanged —
9706    /// the new knobs are dead weight on a listener that never prereads.
9707    #[test]
9708    fn sni_preread_validation_ignored_without_sni_frontend() {
9709        let toml_content = r#"
9710            command_socket = "/tmp/sozu_test.sock"
9711            worker_count = 1
9712            buffer_size = 8192
9713
9714            [[listeners]]
9715            protocol      = "tcp"
9716            address       = "127.0.0.1:9032"
9717            front_timeout = 2
9718
9719            [clusters.a]
9720            protocol       = "tcp"
9721            load_balancing = "ROUND_ROBIN"
9722            frontends = [
9723              { address = "127.0.0.1:9032" }
9724            ]
9725            backends = [ { address = "10.0.0.1:9032" } ]
9726        "#;
9727        let file_config: FileConfig =
9728            toml::from_str(toml_content).expect("Could not parse TOML config");
9729        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9730        assert!(
9731            result.is_ok(),
9732            "a no-SNI TCP listener must ignore sni_preread validation entirely: {result:?}"
9733        );
9734    }
9735
9736    /// The actual use case sozu-proxy/sozu#1279 exists for: two TCP
9737    /// frontends sharing the same `(address, sni)` with disjoint,
9738    /// non-empty `alpn` lists must load successfully and both must be
9739    /// individually reachable — ALPN multiplexing within one SNI. The
9740    /// negative tests above only prove overlap is rejected; this proves
9741    /// non-overlap actually works.
9742    #[test]
9743    fn tcp_frontend_disjoint_alpn_same_sni_both_load() {
9744        let toml_content = r#"
9745            command_socket = "/tmp/sozu_test.sock"
9746            worker_count = 1
9747
9748            [[listeners]]
9749            protocol = "tcp"
9750            address  = "127.0.0.1:9040"
9751
9752            [clusters.h2_cluster]
9753            protocol       = "tcp"
9754            load_balancing = "ROUND_ROBIN"
9755            frontends = [
9756              { address = "127.0.0.1:9040", hostname = "example.com", alpn = ["h2"] }
9757            ]
9758            backends = [ { address = "10.0.0.1:9040" } ]
9759
9760            [clusters.http11_cluster]
9761            protocol       = "tcp"
9762            load_balancing = "ROUND_ROBIN"
9763            frontends = [
9764              { address = "127.0.0.1:9040", hostname = "example.com", alpn = ["http/1.1"] }
9765            ]
9766            backends = [ { address = "10.0.0.2:9040" } ]
9767        "#;
9768        let file_config: FileConfig =
9769            toml::from_str(toml_content).expect("Could not parse TOML config");
9770        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9771            .into_config()
9772            .expect("disjoint non-empty ALPN lists on the same (address, sni) must load");
9773
9774        let messages = config
9775            .generate_config_messages()
9776            .expect("Could not generate config messages");
9777        let mut frontends: Vec<_> = messages
9778            .iter()
9779            .filter_map(|m| match &m.content.request_type {
9780                Some(RequestType::AddTcpFrontend(f)) => Some(f.clone()),
9781                _ => None,
9782            })
9783            .collect();
9784        frontends.sort_by(|a, b| a.cluster_id.cmp(&b.cluster_id));
9785
9786        assert_eq!(frontends.len(), 2, "both frontends must be emitted");
9787        assert_eq!(frontends[0].cluster_id, "h2_cluster");
9788        assert_eq!(frontends[0].sni, Some("example.com".to_string()));
9789        assert_eq!(frontends[0].alpn, vec!["h2".to_string()]);
9790        assert_eq!(frontends[1].cluster_id, "http11_cluster");
9791        assert_eq!(frontends[1].sni, Some("example.com".to_string()));
9792        assert_eq!(frontends[1].alpn, vec!["http/1.1".to_string()]);
9793    }
9794
9795    /// A `protocol = "tcp"` cluster frontend resolved against a
9796    /// `protocol = "udp"` listener (`TcpFrontendConfig.udp = true`, set in
9797    /// `populate_clusters`) emits `AddUdpFrontend`, not `AddTcpFrontend` —
9798    /// it carries no `sni`/`alpn` on the wire and must not participate in
9799    /// the TCP-only mixing-ban / ALPN-overlap invariants. Two "tcp"
9800    /// clusters at the same UDP-listener address, one with `hostname` set
9801    /// and one without, must build and emit their messages rather than
9802    /// spuriously tripping `TcpListenerMixesSniAndNoSni`.
9803    ///
9804    /// Message GENERATION is all this asserts. A UDP address is claimed by
9805    /// exactly one frontend (`ConfigState::add_udp_frontend`), so at apply
9806    /// time `load_static_config` admits the first of these two and skips the
9807    /// second with `Skipping a config entry the state refused`. That is the
9808    /// config-file face of the same collision, and it is deliberately not
9809    /// asserted here: this test owns the SNI invariants, not admission.
9810    #[test]
9811    fn udp_routed_tcp_frontends_are_excluded_from_sni_invariants() {
9812        let toml_content = r#"
9813            command_socket = "/tmp/sozu_test.sock"
9814            worker_count = 1
9815
9816            [[listeners]]
9817            protocol = "udp"
9818            address  = "127.0.0.1:9050"
9819
9820            [clusters.a]
9821            protocol       = "tcp"
9822            load_balancing = "ROUND_ROBIN"
9823            frontends = [
9824              { address = "127.0.0.1:9050", hostname = "example.com" }
9825            ]
9826            backends = [ { address = "10.0.0.1:9050" } ]
9827
9828            [clusters.b]
9829            protocol       = "tcp"
9830            load_balancing = "ROUND_ROBIN"
9831            frontends = [
9832              { address = "127.0.0.1:9050" }
9833            ]
9834            backends = [ { address = "10.0.0.2:9050" } ]
9835        "#;
9836        let file_config: FileConfig =
9837            toml::from_str(toml_content).expect("Could not parse TOML config");
9838        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
9839        assert!(
9840            result.is_ok(),
9841            "UDP-routed tcp-cluster frontends must not trip the SNI mixing-ban: {result:?}"
9842        );
9843
9844        let config = result.expect("checked is_ok above");
9845        let messages = config
9846            .generate_config_messages()
9847            .expect("Could not generate config messages");
9848        let add_udp_frontend_count = messages
9849            .iter()
9850            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpFrontend(_))))
9851            .count();
9852        assert_eq!(
9853            add_udp_frontend_count, 2,
9854            "both cluster frontends on the udp listener must emit AddUdpFrontend"
9855        );
9856    }
9857
9858    /// The three subnet keys must travel from TOML through `Config`,
9859    /// and the per-cluster override with them. Nothing else asserts this
9860    /// plumbing: a swapped or dropped key would only surface as a cap
9861    /// that silently never fires.
9862    #[test]
9863    fn subnet_connection_limit_keys_round_trip_from_toml() {
9864        let toml_content = r#"
9865            command_socket   = "/tmp/sozu.sock"
9866            saved_state      = "./state.json"
9867            worker_count     = 1
9868
9869            max_connections_per_ip     = 10
9870            max_connections_per_subnet = 100
9871            subnet_ipv4_prefix         = 24
9872            subnet_ipv6_prefix         = 56
9873
9874            [[listeners]]
9875            protocol = "http"
9876            address  = "127.0.0.1:8080"
9877
9878            [clusters.api]
9879            protocol       = "http"
9880            load_balancing = "ROUND_ROBIN"
9881            max_connections_per_subnet = 7
9882            frontends = [
9883              { address = "127.0.0.1:8080", hostname = "example.com" }
9884            ]
9885            backends = [ { address = "10.0.0.1:8080" } ]
9886        "#;
9887        let file_config: FileConfig =
9888            toml::from_str(toml_content).expect("Could not parse TOML config");
9889        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9890            .into_config()
9891            .expect("a config with valid subnet prefixes must load");
9892
9893        assert_eq!(
9894            config.max_connections_per_subnet, 100,
9895            "the global subnet cap must survive the TOML round trip"
9896        );
9897        assert_eq!(
9898            config.subnet_ipv4_prefix, 24,
9899            "subnet_ipv4_prefix must survive the TOML round trip"
9900        );
9901        assert_eq!(
9902            config.subnet_ipv6_prefix, 56,
9903            "subnet_ipv6_prefix must survive the TOML round trip — a swap with the IPv6 \
9904             key would leave a cap that silently never fires"
9905        );
9906        assert_eq!(
9907            config.max_connections_per_ip, 10,
9908            "the pre-existing per-IP cap must be unaffected: the two are independent"
9909        );
9910
9911        let cluster = config
9912            .clusters
9913            .get("api")
9914            .expect("the staged cluster must be present");
9915        let override_value = match cluster {
9916            ClusterConfig::Http(http) => http.max_connections_per_subnet,
9917            other => panic!("expected an HTTP cluster, got {other:?}"),
9918        };
9919        assert_eq!(
9920            override_value,
9921            Some(7),
9922            "the per-cluster subnet override must reach the cluster config"
9923        );
9924    }
9925
9926    /// The backend connection attempt budget (sozu-proxy/sozu#1800): five by
9927    /// default, set globally and per cluster from TOML, carried to the worker
9928    /// in `ServerConfig` and to the cluster in its `AddCluster`.
9929    #[test]
9930    fn max_connection_attempts_defaults_to_five_and_round_trips_from_toml() {
9931        let config_with = |global: &str, cluster: &str| -> Config {
9932            let toml_content = format!(
9933                r#"
9934            command_socket = "/tmp/sozu.sock"
9935            saved_state    = "./state.json"
9936            worker_count   = 1
9937            {global}
9938
9939            [[listeners]]
9940            protocol = "http"
9941            address  = "127.0.0.1:8080"
9942
9943            [clusters.api]
9944            protocol       = "http"
9945            load_balancing = "ROUND_ROBIN"
9946            {cluster}
9947            frontends = [
9948              {{ address = "127.0.0.1:8080", hostname = "example.com" }}
9949            ]
9950            backends = [ {{ address = "10.0.0.1:8080" }} ]
9951        "#
9952            );
9953            let file_config: FileConfig =
9954                toml::from_str(&toml_content).expect("Could not parse TOML config");
9955            ConfigBuilder::new(file_config, "/tmp/test_config.toml")
9956                .into_config()
9957                .expect("a valid attempt budget must load")
9958        };
9959        let cluster_attempts = |config: &Config| match config.clusters.get("api") {
9960            Some(ClusterConfig::Http(http)) => http.max_connection_attempts,
9961            other => panic!("expected the HTTP cluster, got {other:?}"),
9962        };
9963        let add_cluster_attempts = |config: &Config| {
9964            config
9965                .generate_config_messages()
9966                .expect("Could not generate config messages")
9967                .into_iter()
9968                .find_map(|message| match message.content.request_type {
9969                    Some(RequestType::AddCluster(cluster)) => Some(cluster.max_connection_attempts),
9970                    _ => None,
9971                })
9972                .expect("the cluster is added")
9973        };
9974
9975        let defaults = config_with("", "");
9976        assert_eq!(DEFAULT_MAX_CONNECTION_ATTEMPTS, 5);
9977        assert_eq!(defaults.max_connection_attempts, 5);
9978        assert_eq!(
9979            ServerConfig::from(&defaults).max_connection_attempts,
9980            Some(5),
9981            "the worker receives the default"
9982        );
9983        assert_eq!(
9984            cluster_attempts(&defaults),
9985            None,
9986            "a cluster inherits by default"
9987        );
9988        assert_eq!(add_cluster_attempts(&defaults), None);
9989
9990        let set = config_with("max_connection_attempts = 2", "max_connection_attempts = 7");
9991        assert_eq!(set.max_connection_attempts, 2);
9992        assert_eq!(ServerConfig::from(&set).max_connection_attempts, Some(2));
9993        assert_eq!(cluster_attempts(&set), Some(7));
9994        assert_eq!(add_cluster_attempts(&set), Some(7));
9995    }
9996
9997    /// A budget allowing no attempt, or more than the attempt counters hold,
9998    /// is rejected at load, globally and per cluster; both bounds are
9999    /// inclusive.
10000    #[test]
10001    fn an_out_of_range_max_connection_attempts_is_rejected() {
10002        let load = |global: &str, cluster: &str| {
10003            let toml_content = format!(
10004                r#"
10005            command_socket = "/tmp/sozu.sock"
10006            saved_state    = "./state.json"
10007            worker_count   = 1
10008            {global}
10009
10010            [[listeners]]
10011            protocol = "http"
10012            address  = "127.0.0.1:8080"
10013
10014            [clusters.api]
10015            protocol       = "http"
10016            load_balancing = "ROUND_ROBIN"
10017            {cluster}
10018            frontends = [
10019              {{ address = "127.0.0.1:8080", hostname = "example.com" }}
10020            ]
10021            backends = [ {{ address = "10.0.0.1:8080" }} ]
10022        "#
10023            );
10024            let file_config: FileConfig =
10025                toml::from_str(&toml_content).expect("Could not parse TOML config");
10026            ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config()
10027        };
10028        for value in ["0", "256"] {
10029            let global = format!("max_connection_attempts = {value}");
10030            assert!(
10031                matches!(
10032                    load(&global, ""),
10033                    Err(ConfigError::InvalidMaxConnectionAttempts {
10034                        cluster_id: None,
10035                        ..
10036                    })
10037                ),
10038                "a global budget of {value} must be rejected"
10039            );
10040            assert!(
10041                matches!(
10042                    load("", &global),
10043                    Err(ConfigError::InvalidMaxConnectionAttempts {
10044                        cluster_id: Some(_),
10045                        ..
10046                    })
10047                ),
10048                "a cluster budget of {value} must be rejected"
10049            );
10050        }
10051        for value in ["1", "255"] {
10052            let budget = format!("max_connection_attempts = {value}");
10053            assert!(load(&budget, &budget).is_ok(), "{value} is in range");
10054        }
10055    }
10056
10057    /// An out-of-range prefix is REJECTED, never silently clamped. A
10058    /// narrowed mask would enforce a policy other than the one written
10059    /// in the file, and nothing in the config would say so.
10060    #[test]
10061    fn an_out_of_range_subnet_prefix_is_rejected_not_clamped() {
10062        let with_prefixes = |v4: &str, v6: &str| -> String {
10063            format!(
10064                r#"
10065            command_socket = "/tmp/sozu.sock"
10066            saved_state    = "./state.json"
10067            worker_count   = 1
10068
10069            max_connections_per_subnet = 100
10070            subnet_ipv4_prefix         = {v4}
10071            subnet_ipv6_prefix         = {v6}
10072
10073            [[listeners]]
10074            protocol = "http"
10075            address  = "127.0.0.1:8080"
10076        "#
10077            )
10078        };
10079
10080        for (v4, v6, key) in [
10081            ("33", "56", "subnet_ipv4_prefix"),
10082            ("24", "129", "subnet_ipv6_prefix"),
10083        ] {
10084            let file_config: FileConfig =
10085                toml::from_str(&with_prefixes(v4, v6)).expect("Could not parse TOML config");
10086            let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
10087            match result {
10088                Err(ConfigError::InvalidSubnetPrefix { key: reported, .. }) => {
10089                    assert_eq!(
10090                        reported, key,
10091                        "the rejection must name the key that is out of range"
10092                    );
10093                }
10094                other => panic!("{key} out of range must be rejected, got {other:?}"),
10095            }
10096        }
10097
10098        // Positive space: both bounds are INCLUSIVE, so the widest legal
10099        // pair must still load.
10100        let file_config: FileConfig =
10101            toml::from_str(&with_prefixes("32", "128")).expect("Could not parse TOML config");
10102        assert!(
10103            ConfigBuilder::new(file_config, "/tmp/test_config.toml")
10104                .into_config()
10105                .is_ok(),
10106            "the full-width prefixes are legal and are the defaults — they must load"
10107        );
10108    }
10109
10110    #[test]
10111    fn old_state_file_without_client_auth_fields_deserializes() {
10112        // A state file written before mTLS landed carries no `client_ca_*`
10113        // fields. `#[serde(default)]` (via build.rs) must let it round-trip,
10114        // defaulting the new repeated fields to empty rather than erroring on a
10115        // missing field.
10116        let address = SocketAddress::new_v4(127, 0, 0, 1, 8443);
10117        let config = ListenerBuilder::new_https(address)
10118            .to_tls(None)
10119            .expect("default HTTPS listener config");
10120
10121        let mut json: serde_json::Value =
10122            serde_json::to_value(&config).expect("serialize HttpsListenerConfig");
10123        let obj = json.as_object_mut().expect("object");
10124        obj.remove("client_ca_certificates");
10125        obj.remove("client_ca_crls");
10126        obj.remove("client_auth");
10127
10128        let restored: HttpsListenerConfig =
10129            serde_json::from_value(json).expect("old state file must deserialize");
10130        assert!(restored.client_ca_certificates.is_empty());
10131        assert!(restored.client_ca_crls.is_empty());
10132        assert_eq!(restored.client_auth, None);
10133    }
10134
10135    #[test]
10136    fn client_auth_fields_rejected_on_non_https_listener() {
10137        // A TCP listener carrying client_auth must be rejected, not silently
10138        // started unauthenticated.
10139        let address = SocketAddress::new_v4(127, 0, 0, 1, 9000);
10140        let mut tcp = ListenerBuilder::new_tcp(address);
10141        tcp.client_auth = Some(ClientAuthConfig::Required);
10142        assert!(matches!(
10143            tcp.to_tcp(None),
10144            Err(ConfigError::ClientAuthOnNonHttps(_))
10145        ));
10146
10147        // A CA bundle without an explicit mode is equally a misconfiguration.
10148        let mut http = ListenerBuilder::new_http(address);
10149        http.client_ca_certificates = Some(vec!["ca.pem".to_owned()]);
10150        assert!(matches!(
10151            http.to_http(None),
10152            Err(ConfigError::ClientAuthOnNonHttps(_))
10153        ));
10154    }
10155
10156    #[test]
10157    fn none_mode_ignores_stale_ca_paths() {
10158        // With client auth disabled, an unreadable CA/CRL path must not block
10159        // the HTTPS listener from materializing.
10160        let address = SocketAddress::new_v4(127, 0, 0, 1, 9443);
10161        let mut https = ListenerBuilder::new_https(address);
10162        https.client_auth = Some(ClientAuthConfig::None);
10163        https.client_ca_certificates = Some(vec!["/nonexistent/ca.pem".to_owned()]);
10164        https.client_ca_crls = Some(vec!["/nonexistent/crl.pem".to_owned()]);
10165        let config = https.to_tls(None).expect("NONE mode must ignore CA paths");
10166        assert!(config.client_ca_certificates.is_empty());
10167        assert!(config.client_ca_crls.is_empty());
10168    }
10169
10170    /// The configuration file names the CA and CRL files; the main process
10171    /// reads them at config-load and sends their contents to the workers, on
10172    /// the verb a worker that predates mutual TLS cannot decode.
10173    #[test]
10174    fn client_auth_listener_inlines_its_ca_and_crl_files_into_the_listener_request() {
10175        let asset =
10176            |name: &str| format!("{}/../lib/assets/mtls/{name}", env!("CARGO_MANIFEST_DIR"));
10177        let (ca_path, crl_path) = (asset("ca-cert.pem"), asset("crl-revoked.pem"));
10178        let toml_content = format!(
10179            r#"
10180            command_socket = "/tmp/sozu.sock"
10181            saved_state    = "./state.json"
10182            worker_count   = 1
10183
10184            [[listeners]]
10185            protocol = "https"
10186            address  = "127.0.0.1:8443"
10187            client_auth = "required"
10188            client_ca_certificates = ["{ca_path}"]
10189            client_ca_crls = ["{crl_path}"]
10190        "#
10191        );
10192        let file_config: FileConfig =
10193            toml::from_str(&toml_content).expect("Could not parse TOML config");
10194        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
10195            .into_config()
10196            .expect("a client auth listener with readable CA and CRL files must load");
10197
10198        let listeners: Vec<HttpsListenerConfig> = config
10199            .generate_config_messages()
10200            .expect("the config must produce its worker requests")
10201            .into_iter()
10202            .filter_map(|request| match request.content.request_type {
10203                Some(RequestType::AddHttpsListenerWithClientAuth(listener)) => Some(listener),
10204                Some(RequestType::AddHttpsListener(_)) => {
10205                    panic!("a client auth listener must not go out as AddHttpsListener")
10206                }
10207                _ => None,
10208            })
10209            .collect();
10210        assert_eq!(listeners.len(), 1, "one HTTPS listener is configured");
10211        let listener = &listeners[0];
10212        assert_eq!(
10213            listener.client_auth,
10214            Some(ClientAuthMode::ClientAuthRequired as i32)
10215        );
10216        assert_eq!(
10217            listener.client_ca_certificates,
10218            vec![std::fs::read_to_string(&ca_path).expect("read the CA fixture")],
10219            "the CA file content, not its path, must reach the worker"
10220        );
10221        assert_eq!(
10222            listener.client_ca_crls,
10223            vec![std::fs::read_to_string(&crl_path).expect("read the CRL fixture")],
10224            "the CRL file content, not its path, must reach the worker"
10225        );
10226        assert!(
10227            listener.client_ca_crls[0].contains("BEGIN X509 CRL"),
10228            "the inlined CRL is the PEM body"
10229        );
10230    }
10231
10232    #[test]
10233    fn client_auth_listener_with_an_unreadable_crl_file_does_not_load() {
10234        // A CRL the operator configured but Sozu cannot read would silently
10235        // disable the revocation they asked for.
10236        let address = SocketAddress::new_v4(127, 0, 0, 1, 9443);
10237        let mut https = ListenerBuilder::new_https(address);
10238        https.client_auth = Some(ClientAuthConfig::Required);
10239        https.client_ca_certificates = Some(vec![format!(
10240            "{}/../lib/assets/mtls/ca-cert.pem",
10241            env!("CARGO_MANIFEST_DIR")
10242        )]);
10243        https.client_ca_crls = Some(vec!["/nonexistent/crl.pem".to_owned()]);
10244        assert!(
10245            https.to_tls(None).is_err(),
10246            "an unreadable CRL file must fail the listener, not drop the revocation check"
10247        );
10248    }
10249
10250    #[test]
10251    fn client_auth_files_without_a_mode_are_rejected() {
10252        // The mode defaults to `none`, which ignores these files: a listener
10253        // naming a CA or a CRL with no mode would start unauthenticated.
10254        let address = SocketAddress::new_v4(127, 0, 0, 1, 9443);
10255        for (cas, crls) in [
10256            (Some(vec!["/etc/sozu/client-ca.pem".to_owned()]), None),
10257            (None, Some(vec!["/etc/sozu/client-ca.crl.pem".to_owned()])),
10258        ] {
10259            let mut https = ListenerBuilder::new_https(address);
10260            https.client_ca_certificates = cas;
10261            https.client_ca_crls = crls;
10262            assert!(matches!(
10263                https.to_tls(None),
10264                Err(ConfigError::ClientAuthModeMissing(_))
10265            ));
10266        }
10267        // Positive space: an explicit `none` keeps the files unused (and is
10268        // only warned about), and no file at all needs no mode.
10269        let mut explicit_none = ListenerBuilder::new_https(address);
10270        explicit_none.client_auth = Some(ClientAuthConfig::None);
10271        explicit_none.client_ca_certificates = Some(vec!["/nonexistent/ca.pem".to_owned()]);
10272        assert!(explicit_none.to_tls(None).is_ok());
10273        assert!(ListenerBuilder::new_https(address).to_tls(None).is_ok());
10274    }
10275
10276    #[test]
10277    fn client_auth_config_parses_documented_lowercase_names() {
10278        // The documented TOML values (`none`/`optional`/`required`) must parse,
10279        // not just the protobuf enum's SCREAMING_SNAKE_CASE.
10280        assert_eq!(
10281            serde_json::from_str::<ClientAuthConfig>("\"required\"").unwrap(),
10282            ClientAuthConfig::Required
10283        );
10284        assert_eq!(
10285            serde_json::from_str::<ClientAuthConfig>("\"optional\"").unwrap(),
10286            ClientAuthConfig::Optional
10287        );
10288        assert_eq!(
10289            serde_json::from_str::<ClientAuthConfig>("\"none\"").unwrap(),
10290            ClientAuthConfig::None
10291        );
10292        assert_eq!(
10293            ClientAuthMode::from(ClientAuthConfig::Required),
10294            ClientAuthMode::ClientAuthRequired
10295        );
10296    }
10297}