Skip to main content

sozu_command_lib/
config.rs

1//! # Sōzu's configuration
2//!
3//! This module is responsible for parsing the `config.toml` provided by the flag `--config`
4//! when starting Sōzu.
5//!
6//! Here is the workflow for generating a working config:
7//!
8//! ```text
9//!     config.toml   ->   FileConfig    ->  ConfigBuilder   ->  Config
10//! ```
11//!
12//! `config.toml` is parsed to `FileConfig`, a structure that itself contains a lot of substructures
13//! whose names start with `File-` and end with `-Config`, like `FileHttpFrontendConfig` for instance.
14//!
15//! The instance of `FileConfig` is then passed to a `ConfigBuilder` that populates a final `Config`
16//! with listeners and clusters.
17//!
18//! To illustrate:
19//!
20//! ```no_run
21//! use sozu_command_lib::config::{FileConfig, ConfigBuilder};
22//!
23//! let file_config = FileConfig::load_from_path("../config.toml")
24//!     .expect("Could not load config.toml");
25//!
26//! let config = ConfigBuilder::new(file_config, "../assets/config.toml")
27//!     .into_config()
28//!     .expect("Could not build config");
29//! ```
30//!
31//! Note that the path to `config.toml` is used twice: the first time, to parse the file,
32//! the second time, to keep the path in the config for later use.
33//!
34//! However, there is a simpler way that combines all this:
35//!
36//! ```no_run
37//! use sozu_command_lib::config::Config;
38//!
39//! let config = Config::load_from_path("../assets/config.toml")
40//!     .expect("Could not build config from the path");
41//! ```
42//!
43//! ## How values are chosen
44//!
45//! Values are chosen in this order of priority:
46//!
47//! 1. values defined in a section of the TOML file, for instance, timeouts for a specific listener
48//! 2. values defined globally in the TOML file, like timeouts or buffer size
49//! 3. if a variable has not been set in the TOML file, it will be set to a default defined here
50use std::{
51    collections::{BTreeMap, HashMap, HashSet},
52    env, fmt,
53    fs::{File, create_dir_all, metadata},
54    io::{ErrorKind, Read},
55    net::SocketAddr,
56    ops::Range,
57    path::PathBuf,
58};
59
60use crate::{
61    ObjectKind,
62    certificate::split_certificate_chain,
63    logging::AccessLogFormat,
64    proto::command::{
65        ActivateListener, AddBackend, AddCertificate, CertificateAndKey, Cluster,
66        CustomHttpAnswers, Header, HeaderPosition, HealthCheckConfig, HstsConfig,
67        HttpListenerConfig, HttpsListenerConfig, ListenerType, LoadBalancingAlgorithms,
68        LoadBalancingParams, LoadMetric, MetricDetail, MetricsConfiguration, PathRule,
69        ProtobufAccessLogFormat, ProxyProtocolConfig, RedirectPolicy, RedirectScheme, Request,
70        RequestHttpFrontend, RequestTcpFrontend, RequestUdpFrontend, RulePosition, ServerConfig,
71        ServerMetricsConfig, SocketAddress, TcpListenerConfig, TlsVersion, UdpAffinityKey,
72        UdpClusterConfig, UdpHealthConfig, UdpHealthMode, UdpListenerConfig, WorkerRequest,
73        request::RequestType,
74    },
75};
76
77/// Authoritative list of default cipher suites for all rustls-based TLS providers.
78///
79/// These use rustls naming conventions and are supported by all three crypto providers
80/// (ring, aws-lc-rs, rustls-openssl). Order follows ANSSI recommendations: AES-256
81/// preferred over AES-128, ECDSA preferred over RSA, TLS 1.3 preferred over TLS 1.2.
82///
83/// See the [documentation](https://docs.rs/rustls/latest/rustls/static.ALL_CIPHER_SUITES.html)
84pub const DEFAULT_CIPHER_LIST: [&str; 9] = [
85    // TLS 1.3 cipher suites
86    "TLS13_AES_256_GCM_SHA384",
87    "TLS13_AES_128_GCM_SHA256",
88    "TLS13_CHACHA20_POLY1305_SHA256",
89    // TLS 1.2 cipher suites
90    "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
91    "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
92    "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
93    "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
94    "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
95    "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
96];
97
98pub const DEFAULT_SIGNATURE_ALGORITHMS: [&str; 9] = [
99    "ECDSA+SHA256",
100    "ECDSA+SHA384",
101    "ECDSA+SHA512",
102    "RSA+SHA256",
103    "RSA+SHA384",
104    "RSA+SHA512",
105    "RSA-PSS+SHA256",
106    "RSA-PSS+SHA384",
107    "RSA-PSS+SHA512",
108];
109
110pub const DEFAULT_GROUPS_LIST: [&str; 4] = ["X25519MLKEM768", "x25519", "P-256", "P-384"];
111
112/// Default ALPN protocols advertised by HTTPS listeners.
113/// Both HTTP/2 and HTTP/1.1 are enabled, allowing clients to negotiate either.
114pub const DEFAULT_ALPN_PROTOCOLS: [&str; 2] = ["h2", "http/1.1"];
115
116/// maximum time of inactivity for a frontend socket (60 seconds)
117pub const DEFAULT_FRONT_TIMEOUT: u32 = 60;
118
119/// maximum time of inactivity for a backend socket (30 seconds)
120pub const DEFAULT_BACK_TIMEOUT: u32 = 30;
121
122/// maximum time to connect to a backend server (3 seconds)
123pub const DEFAULT_CONNECT_TIMEOUT: u32 = 3;
124
125/// maximum time to receive a request since the connection started (10 seconds)
126pub const DEFAULT_REQUEST_TIMEOUT: u32 = 10;
127
128/// client/upstream flow idle timeout for a UDP listener (30 seconds)
129pub const DEFAULT_UDP_FRONT_TIMEOUT: u32 = 30;
130
131/// upstream flow idle timeout for a UDP listener (30 seconds)
132pub const DEFAULT_UDP_BACK_TIMEOUT: u32 = 30;
133
134/// maximum received datagram size for a UDP listener, in bytes (1500 = a
135/// typical Ethernet MTU). Capped at the effective `buffer_size` at runtime.
136pub const DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE: u32 = 1500;
137
138/// maximum number of concurrent UDP flows per listener. `0` selects the
139/// runtime auto policy (~70% of the soft `RLIMIT_NOFILE`).
140pub const DEFAULT_UDP_MAX_FLOWS: u32 = 0;
141
142/// maximum time to wait for a worker to respond, until it is deemed NotAnswering (10 seconds)
143pub const DEFAULT_WORKER_TIMEOUT: u32 = 10;
144
145/// a name applied to sticky sessions ("SOZUBALANCEID")
146pub const DEFAULT_STICKY_NAME: &str = "SOZUBALANCEID";
147
148/// Interval between checking for zombie sessions, (30 minutes)
149pub const DEFAULT_ZOMBIE_CHECK_INTERVAL: u32 = 1_800;
150
151/// timeout to accept connection events in the accept queue (60 seconds)
152pub const DEFAULT_ACCEPT_QUEUE_TIMEOUT: u32 = 60;
153
154/// Default `Strict-Transport-Security: max-age` value (1 year, 31_536_000
155/// seconds) substituted at config-load when an [hsts] block sets
156/// `enabled = true` but omits `max_age`. Matches the HSTS preload list
157/// minimum (https://hstspreload.org/) and the Caddy / Nginx community
158/// recommendation. Operators can override with any `u32`; `max_age = 0`
159/// is the RFC 6797 §11.4 kill switch and is allowed silently.
160pub const DEFAULT_HSTS_MAX_AGE: u32 = 31_536_000;
161
162/// whether to evict least-recently-active sessions when the accept queue is
163/// saturated (false). Defaults to false because during a DDoS the existing
164/// connections are more likely to be legitimate clients than the queued ones;
165/// evicting them would serve the attacker. Enable when overload is dominated
166/// by normal traffic spikes rather than attacks.
167pub const DEFAULT_EVICT_ON_QUEUE_FULL: bool = false;
168
169/// number of workers, i.e. Sōzu processes that scale horizontally (2)
170pub const DEFAULT_WORKER_COUNT: u16 = 2;
171
172/// wether a worker is automatically restarted when it crashes (true)
173pub const DEFAULT_WORKER_AUTOMATIC_RESTART: bool = true;
174
175/// wether to save the state automatically (false)
176pub const DEFAULT_AUTOMATIC_STATE_SAVE: bool = false;
177
178/// minimum number of buffers (1)
179pub const DEFAULT_MIN_BUFFERS: u64 = 1;
180
181/// maximum number of buffers (1 000)
182pub const DEFAULT_MAX_BUFFERS: u64 = 1_000;
183
184/// size of the buffers, in bytes (16 KB)
185pub const DEFAULT_BUFFER_SIZE: u64 = 16_393;
186
187/// minimum buffer size required when any HTTPS listener advertises H2 ALPN.
188///
189/// RFC 9113 §6.5.2 caps `SETTINGS_MAX_FRAME_SIZE` at 16 384 bytes by default;
190/// the on-wire H2 frame header is a fixed 9 bytes (§4.1), so the kawa storage
191/// must be able to hold 16 384 + 9 = 16 393 bytes before forwarding. A smaller
192/// `buffer_size` causes the H2 mux to deadlock on full-size frames (no panic,
193/// no obvious log) until the session timeout fires. Validated at config-load
194/// time in `ConfigBuilder::into_config` so a typo in TOML is rejected at boot,
195/// not discovered under traffic.
196pub const H2_MIN_BUFFER_SIZE: u64 = 16_393;
197
198/// maximum number of simultaneous connections (10 000)
199pub const DEFAULT_MAX_CONNECTIONS: usize = 10_000;
200
201/// size of the buffer for the channels, in bytes. Must be bigger than the size of the data received. (1 MB)
202pub const DEFAULT_COMMAND_BUFFER_SIZE: u64 = 1_000_000;
203
204/// maximum size of the buffer for the channels, in bytes. (2 MB)
205pub const DEFAULT_MAX_COMMAND_BUFFER_SIZE: u64 = 2_000_000;
206
207/// wether to avoid register cluster metrics in the local drain
208pub const DEFAULT_DISABLE_CLUSTER_METRICS: bool = false;
209
210pub const MAX_LOOP_ITERATIONS: usize = 100000;
211
212/// Number of TLS 1.3 tickets to send to a client when establishing a connection.
213/// The tickets allow the client to resume a session. This protects the client
214/// agains session tracking. Increases the number of getrandom syscalls,
215/// with little influence on performance. Defaults to 4.
216pub const DEFAULT_SEND_TLS_13_TICKETS: u64 = 4;
217
218/// for both logs and access logs
219pub const DEFAULT_LOG_TARGET: &str = "stdout";
220
221/// Default per-(cluster, source-IP) connection limit. `0` means unlimited.
222/// Counts are kept per `(cluster_id, source_ip)` so two clusters never
223/// share a counter even from the same IP. Per-cluster overrides on the
224/// `Cluster` message take precedence.
225pub const DEFAULT_MAX_CONNECTIONS_PER_IP: u64 = 0;
226
227/// Default `Retry-After` header value (seconds) on HTTP 429 responses
228/// emitted when a per-(cluster, source-IP) connection limit is hit. `0`
229/// omits the header — `Retry-After: 0` invites an immediate retry that
230/// defeats the limit. TCP rejections do not emit this value (no HTTP
231/// envelope), but the field is accepted for symmetry.
232pub const DEFAULT_RETRY_AFTER: u32 = 60;
233
234#[derive(Debug)]
235pub enum IncompatibilityKind {
236    PublicAddress,
237    ProxyProtocol,
238}
239
240#[derive(Debug)]
241pub enum MissingKind {
242    Field(String),
243    Protocol,
244    SavedState,
245}
246
247#[derive(thiserror::Error, Debug)]
248pub enum ConfigError {
249    #[error("env path not found: {0}")]
250    Env(String),
251    #[error("Could not open file {path_to_open}: {io_error}")]
252    FileOpen {
253        path_to_open: String,
254        io_error: std::io::Error,
255    },
256    #[error("Could not read file {path_to_read}: {io_error}")]
257    FileRead {
258        path_to_read: String,
259        io_error: std::io::Error,
260    },
261    #[error(
262        "the field {kind:?} of {object:?} with id or address {id} is incompatible with the rest of the options"
263    )]
264    Incompatible {
265        kind: IncompatibilityKind,
266        object: ObjectKind,
267        id: String,
268    },
269    #[error("Invalid '{0}' field for a TCP frontend")]
270    InvalidFrontendConfig(String),
271    #[error("invalid path {0:?}")]
272    InvalidPath(PathBuf),
273    #[error("listening address {0:?} is already used in the configuration")]
274    ListenerAddressAlreadyInUse(SocketAddr),
275    #[error("missing {0:?}")]
276    Missing(MissingKind),
277    #[error("could not get parent directory for file {0}")]
278    NoFileParent(String),
279    #[error("Could not get the path of the saved state")]
280    SaveStatePath(String),
281    #[error("Can not determine path to sozu socket: {0}")]
282    SocketPathError(String),
283    #[error("toml decoding error: {0}")]
284    DeserializeToml(String),
285    #[error("Can not set this frontend on a {0:?} listener")]
286    WrongFrontendProtocol(ListenerProtocol),
287    #[error("Can not build a {expected:?} listener from a {found:?} config")]
288    WrongListenerProtocol {
289        expected: ListenerProtocol,
290        found: Option<ListenerProtocol>,
291    },
292    #[error("Invalid ALPN protocol '{0}'. Valid values: \"h2\", \"http/1.1\"")]
293    InvalidAlpnProtocol(String),
294    /// `disable_http11 = true` and `alpn_protocols` containing `"http/1.1"`
295    /// are mutually exclusive: the proxy advertises `http/1.1` to peers,
296    /// then refuses every connection that negotiates
297    /// it. The combination is a self-DoS at handshake time. Either drop
298    /// `http/1.1` from `alpn_protocols` or unset `disable_http11`.
299    #[error(
300        "disable_http11 = true is incompatible with alpn_protocols containing \"http/1.1\" \
301         on listener {address}. The proxy would advertise http/1.1 then refuse every \
302         connection that negotiates it. Drop \"http/1.1\" from alpn_protocols or unset \
303         disable_http11."
304    )]
305    DisableHttp11WithHttp11Alpn { address: String },
306    /// `buffer_size` is below the H2 minimum (16 393 bytes) but at least one
307    /// HTTPS listener advertises `h2` in its ALPN list. The H2 mux requires
308    /// 16 384-byte frame payload + 9-byte header to fit in a single kawa
309    /// buffer; smaller values deadlock streams that carry full-size frames.
310    /// Either raise `buffer_size` to ≥ 16 393 or remove `h2` from the
311    /// affected listeners' `alpn_protocols`.
312    #[error(
313        "buffer_size = {buffer_size} is below the H2 minimum of {minimum} but \
314         {listeners} HTTPS listener(s) advertise H2 ALPN. The H2 mux deadlocks \
315         on full-size frames with smaller buffers. Raise buffer_size to >= {minimum} \
316         or remove \"h2\" from those listeners' alpn_protocols."
317    )]
318    BufferSizeTooSmallForH2 {
319        buffer_size: u64,
320        minimum: u64,
321        listeners: usize,
322    },
323    /// `redirect = "<value>"` on a frontend used a value the parser doesn't
324    /// recognise. Accepted values are `forward`, `permanent`, `unauthorized`
325    /// (case-insensitive).
326    #[error(
327        "invalid redirect policy '{0}'. Valid values: \"forward\", \"permanent\", \"unauthorized\""
328    )]
329    InvalidRedirectPolicy(String),
330    /// `redirect_scheme = "<value>"` on a frontend used a value the parser
331    /// doesn't recognise. Accepted values are `use-same`, `use-http`,
332    /// `use-https` (case-insensitive).
333    #[error(
334        "invalid redirect scheme '{0}'. Valid values: \"use-same\", \"use-http\", \"use-https\""
335    )]
336    InvalidRedirectScheme(String),
337    /// A `[[clusters.<id>.frontends.headers]]` entry carried an unknown
338    /// `position` value. Accepted values are `request`, `response`, `both`
339    /// (case-insensitive).
340    #[error(
341        "invalid header position '{position}' at headers[{index}]. Valid values: \"request\", \"response\", \"both\""
342    )]
343    InvalidHeaderPosition { index: usize, position: String },
344    /// A `[[clusters.<id>.frontends.headers]]` entry contains a forbidden
345    /// byte (NUL, CR, LF, or another C0 control) in its key or value.
346    /// Accepting these would produce HTTP request/response splitting on
347    /// the wire (CWE-113) — the worker's H2 emission path filters them
348    /// at runtime, but the H1 path serialises raw, so we reject at
349    /// config-load time as a defense in depth.
350    #[error(
351        "invalid header bytes in {field} at headers[{index}]: control characters \
352         (NUL / CR / LF / other C0) are forbidden in header keys and values"
353    )]
354    InvalidHeaderBytes { index: usize, field: &'static str },
355    /// An `[hsts]` block populated `max_age`, `include_subdomains`, or
356    /// `preload` but did not set `enabled`. The TOML representation requires
357    /// `enabled` to be present whenever the block is — that single field
358    /// disambiguates "preserve current" / "explicit disable" / "enable" on
359    /// hot-reconfig partial updates.
360    #[error("invalid HSTS config at {0}: `enabled` is required when an [hsts] block is present")]
361    HstsEnabledRequired(String),
362    /// An `[hsts]` block on an HTTP-only listener or frontend. RFC 6797
363    /// §7.2 forbids emitting `Strict-Transport-Security` over plaintext
364    /// HTTP; sozu rejects the configuration at load time so the
365    /// non-conformant policy never ships to a worker.
366    #[error(
367        "invalid HSTS config at {0}: HSTS is only valid on HTTPS listeners and frontends \
368         (RFC 6797 §7.2 forbids the header over plaintext HTTP)"
369    )]
370    HstsOnPlainHttp(String),
371}
372
373/// An HTTP, HTTPS or TCP listener as parsed from the `Listeners` section in the toml
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(deny_unknown_fields)]
376pub struct ListenerBuilder {
377    pub address: SocketAddr,
378    pub protocol: Option<ListenerProtocol>,
379    pub public_address: Option<SocketAddr>,
380    pub answer_301: Option<String>,
381    pub answer_400: Option<String>,
382    pub answer_401: Option<String>,
383    pub answer_404: Option<String>,
384    pub answer_408: Option<String>,
385    pub answer_413: Option<String>,
386    /// RFC 9110 §15.5.20 — returned when the request's `:authority` / `Host`
387    /// host does not match the TLS SNI negotiated for this connection.
388    pub answer_421: Option<String>,
389    pub answer_502: Option<String>,
390    pub answer_503: Option<String>,
391    pub answer_504: Option<String>,
392    pub answer_507: Option<String>,
393    /// RFC 6585 §4 — emitted when a request would have reached a backend
394    /// but the per-(cluster, source-IP) connection limit is full. Honoured
395    /// like the other deprecated `answer_NNN` fields: copies into the
396    /// listener-level `answers` map at the matching status.
397    pub answer_429: Option<String>,
398    pub tls_versions: Option<Vec<TlsVersion>>,
399    pub cipher_list: Option<Vec<String>>,
400    pub cipher_suites: Option<Vec<String>>,
401    pub groups_list: Option<Vec<String>>,
402    pub expect_proxy: Option<bool>,
403    #[serde(default = "default_sticky_name")]
404    pub sticky_name: String,
405    pub certificate: Option<String>,
406    pub certificate_chain: Option<String>,
407    pub key: Option<String>,
408    /// maximum time of inactivity for a frontend socket
409    pub front_timeout: Option<u32>,
410    /// maximum time of inactivity for a backend socket
411    pub back_timeout: Option<u32>,
412    /// maximum time to connect to a backend server
413    pub connect_timeout: Option<u32>,
414    /// maximum time to receive a request since the connection started
415    pub request_timeout: Option<u32>,
416    /// A [Config] to pull defaults from
417    pub config: Option<Config>,
418    /// Number of TLS 1.3 tickets to send to a client when establishing a connection.
419    /// The ticket allow the client to resume a session. This protects the client
420    /// agains session tracking. Defaults to 4.
421    pub send_tls13_tickets: Option<u64>,
422    /// ALPN protocols to advertise during TLS handshake, in order of preference.
423    /// Valid values: "h2", "http/1.1". Defaults to ["h2", "http/1.1"].
424    pub alpn_protocols: Option<Vec<String>>,
425    /// H2 flood detection: max RST_STREAM frames per second window (CVE-2023-44487, CVE-2019-9514)
426    pub h2_max_rst_stream_per_window: Option<u32>,
427    /// H2 flood detection: max PING frames per second window (CVE-2019-9512)
428    pub h2_max_ping_per_window: Option<u32>,
429    /// H2 flood detection: max SETTINGS frames per second window (CVE-2019-9515)
430    pub h2_max_settings_per_window: Option<u32>,
431    /// H2 flood detection: max empty DATA frames per second window (CVE-2019-9518)
432    pub h2_max_empty_data_per_window: Option<u32>,
433    /// H2 flood detection: max connection-level (stream 0) WINDOW_UPDATE
434    /// frames per sliding window. Caps non-zero stream-0 WINDOW_UPDATE floods
435    /// that would otherwise stay under the generic glitch counter. Default: 100.
436    pub h2_max_window_update_stream0_per_window: Option<u32>,
437    /// Name of the correlation header Sozu injects into every request and
438    /// response. Default: `Sozu-Id`. Operators can rebrand (e.g. `X-Edge-Id`)
439    /// without touching code.
440    pub sozu_id_header: Option<String>,
441    /// H2 flood detection: max CONTINUATION frames per header block (CVE-2024-27316)
442    pub h2_max_continuation_frames: Option<u32>,
443    /// H2 flood detection: max accumulated protocol anomalies before ENHANCE_YOUR_CALM
444    pub h2_max_glitch_count: Option<u32>,
445    /// H2 connection-level receive window size in bytes (RFC 9113 §6.9.2). Default: 1048576 (1MB).
446    pub h2_initial_connection_window: Option<u32>,
447    /// Maximum concurrent H2 streams (SETTINGS_MAX_CONCURRENT_STREAMS). Default: 100.
448    pub h2_max_concurrent_streams: Option<u32>,
449    /// Shrink threshold ratio for recycled stream slots. Default: 2.
450    pub h2_stream_shrink_ratio: Option<u32>,
451    /// H2 flood detection: absolute lifetime cap on RST_STREAM frames
452    /// received on a single connection (CVE-2023-44487). Default: 10000.
453    pub h2_max_rst_stream_lifetime: Option<u64>,
454    /// H2 flood detection: lifetime cap on "abusive" (pre-response-start)
455    /// RST_STREAM frames (Rapid Reset signature, CVE-2023-44487). Default: 50.
456    pub h2_max_rst_stream_abusive_lifetime: Option<u64>,
457    /// H2 flood detection: absolute lifetime cap on **server-emitted**
458    /// RST_STREAM frames (CVE-2025-8671 "MadeYouReset"). Only non-`NoError`
459    /// resets count — graceful cancels are exempt. Default: 500.
460    pub h2_max_rst_stream_emitted_lifetime: Option<u64>,
461    /// H2 flood detection: maximum accumulated HPACK-decoded header list
462    /// size per request (SETTINGS_MAX_HEADER_LIST_SIZE, RFC 9113 §6.5.2).
463    /// Default: 65536.
464    pub h2_max_header_list_size: Option<u32>,
465    /// Maximum HPACK dynamic table size (SETTINGS_HEADER_TABLE_SIZE) accepted
466    /// from the peer. Caps the value the peer advertises in SETTINGS frames to
467    /// prevent unbounded HPACK encoder memory growth. Default: 65536.
468    pub h2_max_header_table_size: Option<u32>,
469    /// Maximum number of materialized header fields per request — HPACK fields
470    /// plus expanded cookie crumbs (RFC 9113 §8.2.3). Bounds the HPACK
471    /// indexed-reference header bomb. Default: 128.
472    pub h2_max_header_fields: Option<u32>,
473    /// Per-stream idle timeout, in seconds. An open H2 stream that makes no
474    /// forward progress for this duration is cancelled (RST_STREAM / CANCEL)
475    /// to defend against slow-multiplex Slowloris. Default: 30.
476    pub h2_stream_idle_timeout_seconds: Option<u32>,
477    /// Maximum wall-clock seconds to wait for in-flight H2 streams after
478    /// `GOAWAY(NO_ERROR)` has been sent during soft-stop. Once the deadline
479    /// elapses the connection is forcibly closed with a final GOAWAY. Set to
480    /// `0` to wait for streams to finish (no forced close). Default: 5.
481    pub h2_graceful_shutdown_deadline_seconds: Option<u32>,
482    /// When true, every HTTP request served on this listener must have its
483    /// `:authority` / `Host` host exact-match the TLS SNI negotiated at
484    /// handshake (CWE-346 / CWE-444). Applies to HTTPS listeners only;
485    /// plaintext HTTP listeners never have an SNI to compare against.
486    /// Default: true.
487    pub strict_sni_binding: Option<bool>,
488    /// When true, this HTTPS listener only accepts HTTP/2 connections;
489    /// clients that do not negotiate `h2` via TLS ALPN (including those
490    /// that omit ALPN entirely) are dropped at handshake instead of
491    /// silently downgrading to HTTP/1.1. Default: false.
492    pub disable_http11: Option<bool>,
493    /// When true, any client-supplied `X-Real-IP` header is stripped from
494    /// requests before forwarding (anti-spoofing). Independently combinable
495    /// with `send_x_real_ip`. Default: false.
496    pub elide_x_real_ip: Option<bool>,
497    /// When true, a proxy-generated `X-Real-IP` header carrying the
498    /// connection peer IP (post-PROXY-v2 unwrap, i.e. the original client
499    /// IP) is appended to every forwarded request. Independently combinable
500    /// with `elide_x_real_ip`. Default: false.
501    pub send_x_real_ip: Option<bool>,
502    /// Per-status HTTP answer templates at listener scope — the **global
503    /// default** that fires whenever no cluster-level override matches.
504    /// Map key is the HTTP status code (e.g. `"503"`); map value is
505    /// either a filesystem path or an `inline:<body>` literal, see
506    /// [`resolve_answer_source`]. Loaded into
507    /// [`HttpListenerConfig::answers`] / [`HttpsListenerConfig::answers`]
508    /// at build time via [`load_answers`].
509    ///
510    /// Cluster-level [`FileClusterConfig::answers`] entries override the
511    /// matching status here for requests routed to that cluster.
512    ///
513    /// The deprecated per-status `answer_NNN` fields are still honoured
514    /// for backwards compatibility but are equivalent to a one-line entry
515    /// in this map; new configs should prefer `[listeners.answers]`.
516    pub answers: Option<BTreeMap<String, String>>,
517    /// Listener-default HSTS (RFC 6797) policy. When set, every HTTPS
518    /// frontend on this listener that does not declare its own `[hsts]`
519    /// block inherits this value. Per RFC 6797 §7.2 HSTS is rejected on
520    /// HTTP listeners at config-load time; this field is only meaningful
521    /// for HTTPS listeners. Defaults to `None` (no HSTS).
522    pub hsts: Option<FileHstsConfig>,
523    /// UDP listener only: maximum received datagram size, in bytes. Capped
524    /// at the effective `buffer_size` at config-load (clamp + warn when
525    /// larger). Defaults to [`DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE`].
526    pub max_rx_datagram_size: Option<u32>,
527    /// UDP listener only: maximum number of concurrent flows. `0` (the
528    /// default) selects the runtime auto policy (~70% soft RLIMIT_NOFILE);
529    /// a warning is emitted at config-load when an explicit value exceeds
530    /// that bound.
531    pub max_flows: Option<u32>,
532}
533
534pub fn default_sticky_name() -> String {
535    DEFAULT_STICKY_NAME.to_string()
536}
537
538impl ListenerBuilder {
539    /// starts building an HTTP Listener with config values for timeouts,
540    /// or defaults if no config is provided
541    pub fn new_http(address: SocketAddress) -> ListenerBuilder {
542        Self::new(address, ListenerProtocol::Http)
543    }
544
545    /// starts building an HTTPS Listener with config values for timeouts,
546    /// or defaults if no config is provided
547    pub fn new_tcp(address: SocketAddress) -> ListenerBuilder {
548        Self::new(address, ListenerProtocol::Tcp)
549    }
550
551    /// starts building a TCP Listener with config values for timeouts,
552    /// or defaults if no config is provided
553    pub fn new_https(address: SocketAddress) -> ListenerBuilder {
554        Self::new(address, ListenerProtocol::Https)
555    }
556
557    /// starts building a UDP Listener with config values for timeouts,
558    /// or defaults if no config is provided
559    pub fn new_udp(address: SocketAddress) -> ListenerBuilder {
560        Self::new(address, ListenerProtocol::Udp)
561    }
562
563    /// starts building a Listener
564    fn new(address: SocketAddress, protocol: ListenerProtocol) -> ListenerBuilder {
565        ListenerBuilder {
566            address: address.into(),
567            answer_301: None,
568            answer_401: None,
569            answer_400: None,
570            answer_404: None,
571            answer_408: None,
572            answer_413: None,
573            answer_421: None,
574            answer_502: None,
575            answer_503: None,
576            answer_504: None,
577            answer_507: None,
578            answer_429: None,
579            back_timeout: None,
580            certificate_chain: None,
581            certificate: None,
582            cipher_list: None,
583            cipher_suites: None,
584            groups_list: None,
585            config: None,
586            connect_timeout: None,
587            expect_proxy: None,
588            front_timeout: None,
589            key: None,
590            protocol: Some(protocol),
591            public_address: None,
592            request_timeout: None,
593            send_tls13_tickets: None,
594            sticky_name: DEFAULT_STICKY_NAME.to_string(),
595            tls_versions: None,
596            alpn_protocols: None,
597            h2_max_rst_stream_per_window: None,
598            h2_max_ping_per_window: None,
599            h2_max_settings_per_window: None,
600            h2_max_empty_data_per_window: None,
601            h2_max_window_update_stream0_per_window: None,
602            sozu_id_header: None,
603            h2_max_continuation_frames: None,
604            h2_max_glitch_count: None,
605            h2_initial_connection_window: None,
606            h2_max_concurrent_streams: None,
607            h2_stream_shrink_ratio: None,
608            h2_max_rst_stream_lifetime: None,
609            h2_max_rst_stream_abusive_lifetime: None,
610            h2_max_rst_stream_emitted_lifetime: None,
611            h2_max_header_list_size: None,
612            h2_max_header_table_size: None,
613            h2_max_header_fields: None,
614            h2_stream_idle_timeout_seconds: None,
615            h2_graceful_shutdown_deadline_seconds: None,
616            strict_sni_binding: None,
617            disable_http11: None,
618            elide_x_real_ip: None,
619            send_x_real_ip: None,
620            answers: None,
621            hsts: None,
622            max_rx_datagram_size: None,
623            max_flows: None,
624        }
625    }
626
627    pub fn with_public_address(&mut self, public_address: Option<SocketAddr>) -> &mut Self {
628        if let Some(address) = public_address {
629            self.public_address = Some(address);
630        }
631        self
632    }
633
634    pub fn with_answer_404_path<S>(&mut self, answer_404_path: Option<S>) -> &mut Self
635    where
636        S: ToString,
637    {
638        if let Some(path) = answer_404_path {
639            self.answer_404 = Some(path.to_string());
640        }
641        self
642    }
643
644    pub fn with_answer_503_path<S>(&mut self, answer_503_path: Option<S>) -> &mut Self
645    where
646        S: ToString,
647    {
648        if let Some(path) = answer_503_path {
649            self.answer_503 = Some(path.to_string());
650        }
651        self
652    }
653
654    pub fn with_tls_versions(&mut self, tls_versions: Vec<TlsVersion>) -> &mut Self {
655        self.tls_versions = Some(tls_versions);
656        self
657    }
658
659    pub fn with_cipher_list(&mut self, cipher_list: Option<Vec<String>>) -> &mut Self {
660        self.cipher_list = cipher_list;
661        self
662    }
663
664    pub fn with_cipher_suites(&mut self, cipher_suites: Option<Vec<String>>) -> &mut Self {
665        self.cipher_suites = cipher_suites;
666        self
667    }
668
669    pub fn with_alpn_protocols(&mut self, alpn_protocols: Option<Vec<String>>) -> &mut Self {
670        self.alpn_protocols = alpn_protocols;
671        self
672    }
673
674    /// When true, strip any client-supplied `X-Real-IP` header from
675    /// forwarded requests (anti-spoofing). Default: false.
676    pub fn with_elide_x_real_ip(&mut self, elide_x_real_ip: bool) -> &mut Self {
677        self.elide_x_real_ip = Some(elide_x_real_ip);
678        self
679    }
680
681    /// When true, append a proxy-generated `X-Real-IP` header carrying the
682    /// connection peer IP (post-PROXY-v2 unwrap) to every forwarded request.
683    /// Default: false.
684    pub fn with_send_x_real_ip(&mut self, send_x_real_ip: bool) -> &mut Self {
685        self.send_x_real_ip = Some(send_x_real_ip);
686        self
687    }
688
689    pub fn with_expect_proxy(&mut self, expect_proxy: bool) -> &mut Self {
690        self.expect_proxy = Some(expect_proxy);
691        self
692    }
693
694    pub fn with_sticky_name<S>(&mut self, sticky_name: Option<S>) -> &mut Self
695    where
696        S: ToString,
697    {
698        if let Some(name) = sticky_name {
699            self.sticky_name = name.to_string();
700        }
701        self
702    }
703
704    pub fn with_certificate<S>(&mut self, certificate: S) -> &mut Self
705    where
706        S: ToString,
707    {
708        self.certificate = Some(certificate.to_string());
709        self
710    }
711
712    pub fn with_certificate_chain(&mut self, certificate_chain: String) -> &mut Self {
713        self.certificate = Some(certificate_chain);
714        self
715    }
716
717    pub fn with_key<S>(&mut self, key: String) -> &mut Self
718    where
719        S: ToString,
720    {
721        self.key = Some(key);
722        self
723    }
724
725    pub fn with_front_timeout(&mut self, front_timeout: Option<u32>) -> &mut Self {
726        self.front_timeout = front_timeout;
727        self
728    }
729
730    pub fn with_back_timeout(&mut self, back_timeout: Option<u32>) -> &mut Self {
731        self.back_timeout = back_timeout;
732        self
733    }
734
735    pub fn with_connect_timeout(&mut self, connect_timeout: Option<u32>) -> &mut Self {
736        self.connect_timeout = connect_timeout;
737        self
738    }
739
740    pub fn with_request_timeout(&mut self, request_timeout: Option<u32>) -> &mut Self {
741        self.request_timeout = request_timeout;
742        self
743    }
744
745    /// Register a single per-status answer template file path on this
746    /// listener. The path is read off disk into the resulting listener's
747    /// `answers` map at build time via [`load_answers`]. Repeated calls
748    /// with the same status code overwrite the prior entry.
749    pub fn with_answer<S, P>(&mut self, code: S, path: P) -> &mut Self
750    where
751        S: ToString,
752        P: ToString,
753    {
754        self.answers
755            .get_or_insert_with(BTreeMap::new)
756            .insert(code.to_string(), path.to_string());
757        self
758    }
759
760    /// Replace the listener-scope answer-template path map. See
761    /// [`Self::with_answer`].
762    pub fn with_answers(&mut self, answers: BTreeMap<String, String>) -> &mut Self {
763        self.answers = Some(answers);
764        self
765    }
766
767    /// Get the custom HTTP answers from the file system using the provided paths
768    fn get_http_answers(&self) -> Result<Option<CustomHttpAnswers>, ConfigError> {
769        let http_answers = CustomHttpAnswers {
770            answer_301: read_http_answer_file(&self.answer_301)?,
771            answer_400: read_http_answer_file(&self.answer_400)?,
772            answer_401: read_http_answer_file(&self.answer_401)?,
773            answer_404: read_http_answer_file(&self.answer_404)?,
774            answer_408: read_http_answer_file(&self.answer_408)?,
775            answer_413: read_http_answer_file(&self.answer_413)?,
776            answer_421: read_http_answer_file(&self.answer_421)?,
777            answer_502: read_http_answer_file(&self.answer_502)?,
778            answer_503: read_http_answer_file(&self.answer_503)?,
779            answer_504: read_http_answer_file(&self.answer_504)?,
780            answer_507: read_http_answer_file(&self.answer_507)?,
781            answer_429: read_http_answer_file(&self.answer_429)?,
782        };
783        Ok(Some(http_answers))
784    }
785
786    /// Build the proto-side `answers` map for this listener.
787    ///
788    /// Merges, in order:
789    /// 1. legacy per-status `answer_NNN` fields (if set), so legacy state
790    ///    files round-trip into the new shape;
791    /// 2. the explicit `[listeners.answers]` map (loaded via [`load_answers`]),
792    ///    so new entries take precedence over legacy ones.
793    fn get_listener_answers(&self) -> Result<BTreeMap<String, String>, ConfigError> {
794        let mut out = BTreeMap::new();
795
796        // Pull bodies from the legacy per-status fields first so the new map
797        // takes precedence on collision. Empty bodies are skipped to keep the
798        // proto map minimal.
799        macro_rules! merge_legacy {
800            ($code:literal, $field:ident) => {
801                if let Some(body) = read_http_answer_file(&self.$field)? {
802                    out.insert($code.to_owned(), body);
803                }
804            };
805        }
806        merge_legacy!("301", answer_301);
807        merge_legacy!("400", answer_400);
808        merge_legacy!("401", answer_401);
809        merge_legacy!("404", answer_404);
810        merge_legacy!("408", answer_408);
811        merge_legacy!("413", answer_413);
812        merge_legacy!("421", answer_421);
813        merge_legacy!("502", answer_502);
814        merge_legacy!("503", answer_503);
815        merge_legacy!("504", answer_504);
816        merge_legacy!("507", answer_507);
817        merge_legacy!("429", answer_429);
818
819        if let Some(map) = &self.answers {
820            let loaded = load_answers(map)?;
821            out.extend(loaded);
822        }
823        Ok(out)
824    }
825
826    /// Assign the timeouts of the config to this listener, only if timeouts did not exist
827    fn assign_config_timeouts(&mut self, config: &Config) {
828        self.front_timeout = Some(self.front_timeout.unwrap_or(config.front_timeout));
829        self.back_timeout = Some(self.back_timeout.unwrap_or(config.back_timeout));
830        self.connect_timeout = Some(self.connect_timeout.unwrap_or(config.connect_timeout));
831        self.request_timeout = Some(self.request_timeout.unwrap_or(config.request_timeout));
832    }
833
834    /// build an HTTP listener with config timeouts, using defaults if no config is provided
835    pub fn to_http(&mut self, config: Option<&Config>) -> Result<HttpListenerConfig, ConfigError> {
836        if self.protocol != Some(ListenerProtocol::Http) {
837            return Err(ConfigError::WrongListenerProtocol {
838                expected: ListenerProtocol::Http,
839                found: self.protocol.to_owned(),
840            });
841        }
842
843        // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on
844        // plaintext-HTTP responses. Reject an `[hsts]` block on an HTTP
845        // listener at config-load — `HttpListenerConfig` has no `hsts`
846        // field, so silently dropping the operator's intent would be a
847        // worse failure mode than a typed error here.
848        if self.hsts.is_some() {
849            return Err(ConfigError::HstsOnPlainHttp(format!(
850                "HTTP listener {}",
851                self.address
852            )));
853        }
854
855        if let Some(config) = config {
856            self.assign_config_timeouts(config);
857        }
858
859        let http_answers = self.get_http_answers()?;
860        let answers = self.get_listener_answers()?;
861
862        let configuration = HttpListenerConfig {
863            address: self.address.into(),
864            public_address: self.public_address.map(|a| a.into()),
865            expect_proxy: self.expect_proxy.unwrap_or(false),
866            sticky_name: self.sticky_name.clone(),
867            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
868            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
869            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
870            request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
871            http_answers,
872            answers,
873            h2_max_rst_stream_per_window: self.h2_max_rst_stream_per_window,
874            h2_max_ping_per_window: self.h2_max_ping_per_window,
875            h2_max_settings_per_window: self.h2_max_settings_per_window,
876            h2_max_empty_data_per_window: self.h2_max_empty_data_per_window,
877            h2_max_window_update_stream0_per_window: self.h2_max_window_update_stream0_per_window,
878            h2_max_continuation_frames: self.h2_max_continuation_frames,
879            h2_max_glitch_count: self.h2_max_glitch_count,
880            h2_initial_connection_window: self.h2_initial_connection_window,
881            h2_max_concurrent_streams: self.h2_max_concurrent_streams,
882            h2_stream_shrink_ratio: self.h2_stream_shrink_ratio,
883            h2_max_rst_stream_lifetime: self.h2_max_rst_stream_lifetime,
884            h2_max_rst_stream_abusive_lifetime: self.h2_max_rst_stream_abusive_lifetime,
885            h2_max_rst_stream_emitted_lifetime: self.h2_max_rst_stream_emitted_lifetime,
886            h2_max_header_list_size: self.h2_max_header_list_size,
887            h2_max_header_table_size: self.h2_max_header_table_size,
888            h2_max_header_fields: self.h2_max_header_fields,
889            h2_stream_idle_timeout_seconds: self.h2_stream_idle_timeout_seconds,
890            h2_graceful_shutdown_deadline_seconds: self.h2_graceful_shutdown_deadline_seconds,
891            sozu_id_header: self.sozu_id_header.clone(),
892            elide_x_real_ip: Some(self.elide_x_real_ip.unwrap_or(false)),
893            send_x_real_ip: Some(self.send_x_real_ip.unwrap_or(false)),
894            ..Default::default()
895        };
896
897        // POST: the built listener binds exactly the address that was
898        // requested — a listener whose address drifted here would bind the
899        // wrong socket. (We reached this point only because the protocol guard
900        // at entry confirmed this is an HTTP listener.)
901        debug_assert_eq!(
902            configuration.address,
903            self.address.into(),
904            "HTTP listener must bind the requested address"
905        );
906        Ok(configuration)
907    }
908
909    /// build an HTTPS listener using defaults if no config or values were provided upstream
910    pub fn to_tls(&mut self, config: Option<&Config>) -> Result<HttpsListenerConfig, ConfigError> {
911        if self.protocol != Some(ListenerProtocol::Https) {
912            return Err(ConfigError::WrongListenerProtocol {
913                expected: ListenerProtocol::Https,
914                found: self.protocol.to_owned(),
915            });
916        }
917
918        let default_cipher_list = DEFAULT_CIPHER_LIST.into_iter().map(String::from).collect();
919
920        let cipher_list = self.cipher_list.clone().unwrap_or(default_cipher_list);
921
922        let cipher_suites = self
923            .cipher_suites
924            .clone()
925            .unwrap_or_else(|| DEFAULT_CIPHER_LIST.into_iter().map(String::from).collect());
926
927        let signature_algorithms: Vec<String> = DEFAULT_SIGNATURE_ALGORITHMS
928            .into_iter()
929            .map(String::from)
930            .collect();
931
932        let groups_list = self
933            .groups_list
934            .clone()
935            .unwrap_or_else(|| DEFAULT_GROUPS_LIST.into_iter().map(String::from).collect());
936
937        let alpn_protocols: Vec<String> = match &self.alpn_protocols {
938            Some(protos) if !protos.is_empty() => {
939                for proto in protos {
940                    match proto.as_str() {
941                        "h2" | "http/1.1" => {}
942                        other => return Err(ConfigError::InvalidAlpnProtocol(other.to_owned())),
943                    }
944                }
945                // disable_http11 + http/1.1 ALPN is a self-DoS — every
946                // connection negotiates http/1.1 then is
947                // immediately refused at `https.rs::upgrade_handshake`.
948                // Reject the combination at config load.
949                if self.disable_http11.unwrap_or(false) && protos.iter().any(|p| p == "http/1.1") {
950                    return Err(ConfigError::DisableHttp11WithHttp11Alpn {
951                        address: self.address.to_string(),
952                    });
953                }
954                if !protos.iter().any(|p| p == "http/1.1") {
955                    warn!(
956                        "ALPN protocols do not include 'http/1.1'. Clients without H2 support will fail TLS negotiation."
957                    );
958                }
959                // Deduplicate while preserving order
960                let mut seen = std::collections::HashSet::new();
961                protos
962                    .iter()
963                    .filter(|p| seen.insert(p.as_str()))
964                    .cloned()
965                    .collect()
966            }
967            _ => {
968                // Same self-DoS check on the default ALPN list (which
969                // contains "http/1.1") — `disable_http11 = true` with the
970                // implicit default ALPN must also be rejected.
971                if self.disable_http11.unwrap_or(false)
972                    && DEFAULT_ALPN_PROTOCOLS.contains(&"http/1.1")
973                {
974                    return Err(ConfigError::DisableHttp11WithHttp11Alpn {
975                        address: self.address.to_string(),
976                    });
977                }
978                DEFAULT_ALPN_PROTOCOLS
979                    .iter()
980                    .map(|s| s.to_string())
981                    .collect()
982            }
983        };
984
985        let versions = match self.tls_versions {
986            None => vec![TlsVersion::TlsV12 as i32, TlsVersion::TlsV13 as i32],
987            Some(ref v) => v.iter().map(|v| *v as i32).collect(),
988        };
989
990        let key = self.key.as_ref().and_then(|path| {
991            Config::load_file(path)
992                .map_err(|e| {
993                    error!("cannot load key at path '{}': {:?}", path, e);
994                    e
995                })
996                .ok()
997        });
998        let certificate = self.certificate.as_ref().and_then(|path| {
999            Config::load_file(path)
1000                .map_err(|e| {
1001                    error!("cannot load certificate at path '{}': {:?}", path, e);
1002                    e
1003                })
1004                .ok()
1005        });
1006        let certificate_chain = self
1007            .certificate_chain
1008            .as_ref()
1009            .and_then(|path| {
1010                Config::load_file(path)
1011                    .map_err(|e| {
1012                        error!("cannot load certificate chain at path '{}': {:?}", path, e);
1013                        e
1014                    })
1015                    .ok()
1016            })
1017            .map(split_certificate_chain)
1018            .unwrap_or_default();
1019
1020        let http_answers = self.get_http_answers()?;
1021        let answers = self.get_listener_answers()?;
1022
1023        if let Some(config) = config {
1024            self.assign_config_timeouts(config);
1025        }
1026
1027        let https_listener_config = HttpsListenerConfig {
1028            address: self.address.into(),
1029            sticky_name: self.sticky_name.clone(),
1030            public_address: self.public_address.map(|a| a.into()),
1031            cipher_list,
1032            versions,
1033            expect_proxy: self.expect_proxy.unwrap_or(false),
1034            key,
1035            certificate,
1036            certificate_chain,
1037            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1038            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1039            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1040            request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
1041            cipher_suites,
1042            signature_algorithms,
1043            groups_list,
1044            active: false,
1045            send_tls13_tickets: self
1046                .send_tls13_tickets
1047                .unwrap_or(DEFAULT_SEND_TLS_13_TICKETS),
1048            http_answers,
1049            answers,
1050            alpn_protocols,
1051            h2_max_rst_stream_per_window: self.h2_max_rst_stream_per_window,
1052            h2_max_ping_per_window: self.h2_max_ping_per_window,
1053            h2_max_settings_per_window: self.h2_max_settings_per_window,
1054            h2_max_empty_data_per_window: self.h2_max_empty_data_per_window,
1055            h2_max_window_update_stream0_per_window: self.h2_max_window_update_stream0_per_window,
1056            h2_max_continuation_frames: self.h2_max_continuation_frames,
1057            h2_max_glitch_count: self.h2_max_glitch_count,
1058            h2_initial_connection_window: self.h2_initial_connection_window,
1059            h2_max_concurrent_streams: self.h2_max_concurrent_streams,
1060            h2_stream_shrink_ratio: self.h2_stream_shrink_ratio,
1061            h2_max_rst_stream_lifetime: self.h2_max_rst_stream_lifetime,
1062            h2_max_rst_stream_abusive_lifetime: self.h2_max_rst_stream_abusive_lifetime,
1063            h2_max_rst_stream_emitted_lifetime: self.h2_max_rst_stream_emitted_lifetime,
1064            h2_max_header_list_size: self.h2_max_header_list_size,
1065            h2_max_header_table_size: self.h2_max_header_table_size,
1066            h2_max_header_fields: self.h2_max_header_fields,
1067            strict_sni_binding: self.strict_sni_binding,
1068            disable_http11: self.disable_http11,
1069            h2_stream_idle_timeout_seconds: self.h2_stream_idle_timeout_seconds,
1070            h2_graceful_shutdown_deadline_seconds: self.h2_graceful_shutdown_deadline_seconds,
1071            sozu_id_header: self.sozu_id_header.clone(),
1072            elide_x_real_ip: Some(self.elide_x_real_ip.unwrap_or(false)),
1073            send_x_real_ip: Some(self.send_x_real_ip.unwrap_or(false)),
1074            hsts: match self.hsts.as_ref() {
1075                Some(h) => Some(h.to_proto("listener")?),
1076                None => None,
1077            },
1078        };
1079
1080        // POST: the built listener binds the requested address and starts
1081        // inactive (the protocol guard at entry confirmed this is an HTTPS
1082        // listener).
1083        debug_assert_eq!(
1084            https_listener_config.address,
1085            self.address.into(),
1086            "HTTPS listener must bind the requested address"
1087        );
1088        debug_assert!(
1089            !https_listener_config.active,
1090            "a freshly built HTTPS listener must start inactive"
1091        );
1092        // POST: the resolved ALPN list is non-empty, contains only the two
1093        // protocols Sōzu speaks, and is duplicate-free — the validation/dedup
1094        // branches above are the sole producers, so a malformed list here would
1095        // mean an unvalidated path slipped through.
1096        debug_assert!(
1097            !https_listener_config.alpn_protocols.is_empty(),
1098            "resolved ALPN list must not be empty"
1099        );
1100        debug_assert!(
1101            https_listener_config
1102                .alpn_protocols
1103                .iter()
1104                .all(|p| p == "h2" || p == "http/1.1"),
1105            "resolved ALPN list must contain only h2 and http/1.1"
1106        );
1107        debug_assert!(
1108            {
1109                let mut seen = std::collections::HashSet::new();
1110                https_listener_config
1111                    .alpn_protocols
1112                    .iter()
1113                    .all(|p| seen.insert(p))
1114            },
1115            "resolved ALPN list must be duplicate-free"
1116        );
1117        // POST: disable_http11 + http/1.1 in ALPN is a self-DoS that the
1118        // validation above rejects — an Ok return must never carry that combo.
1119        debug_assert!(
1120            !(self.disable_http11.unwrap_or(false)
1121                && https_listener_config
1122                    .alpn_protocols
1123                    .iter()
1124                    .any(|p| p == "http/1.1")),
1125            "disable_http11 with http/1.1 in ALPN must have been rejected"
1126        );
1127        Ok(https_listener_config)
1128    }
1129
1130    /// build an HTTPS listener using defaults if no config or values were provided upstream
1131    pub fn to_tcp(&mut self, config: Option<&Config>) -> Result<TcpListenerConfig, ConfigError> {
1132        if self.protocol != Some(ListenerProtocol::Tcp) {
1133            return Err(ConfigError::WrongListenerProtocol {
1134                expected: ListenerProtocol::Tcp,
1135                found: self.protocol.to_owned(),
1136            });
1137        }
1138
1139        if let Some(config) = config {
1140            self.assign_config_timeouts(config);
1141        }
1142
1143        let tcp_listener_config = TcpListenerConfig {
1144            address: self.address.into(),
1145            public_address: self.public_address.map(|a| a.into()),
1146            expect_proxy: self.expect_proxy.unwrap_or(false),
1147            front_timeout: self.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
1148            back_timeout: self.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
1149            connect_timeout: self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT),
1150            active: false,
1151        };
1152
1153        // POST: the built listener binds exactly the requested address and is
1154        // created inactive (activation is a later, explicit step).
1155        debug_assert_eq!(
1156            tcp_listener_config.address,
1157            self.address.into(),
1158            "TCP listener must bind the requested address"
1159        );
1160        debug_assert!(
1161            !tcp_listener_config.active,
1162            "a freshly built TCP listener must start inactive"
1163        );
1164        Ok(tcp_listener_config)
1165    }
1166
1167    /// build a UDP listener. UDP has no `expect_proxy` / `connect_timeout`;
1168    /// flows are keyed by 4-tuple and torn down on idle.
1169    ///
1170    /// Timeouts: an unset `front_timeout` / `back_timeout` falls back to the
1171    /// UDP-specific defaults ([`DEFAULT_UDP_FRONT_TIMEOUT`] /
1172    /// [`DEFAULT_UDP_BACK_TIMEOUT`], both 30 s) — *not* the global HTTP/TCP
1173    /// `front_timeout` (60 s) / `back_timeout`. This keeps the effective
1174    /// default in lock-step with the CLI help and the proto
1175    /// `UdpListenerConfig` defaults (both 30 s). The global config is consulted
1176    /// only for `buffer_size` (the `max_rx_datagram_size` cap).
1177    ///
1178    /// Validation:
1179    /// * `max_rx_datagram_size` is clamped to the effective `buffer_size`
1180    ///   (with a warning) so a datagram can never exceed the pool buffer.
1181    /// * an explicit non-zero `max_flows` that exceeds ~70% of the soft
1182    ///   `RLIMIT_NOFILE` emits a warning (the per-flow connected sockets
1183    ///   would otherwise risk EMFILE).
1184    pub fn to_udp(&mut self, config: Option<&Config>) -> Result<UdpListenerConfig, ConfigError> {
1185        if self.protocol != Some(ListenerProtocol::Udp) {
1186            return Err(ConfigError::WrongListenerProtocol {
1187                expected: ListenerProtocol::Udp,
1188                found: self.protocol.to_owned(),
1189            });
1190        }
1191
1192        let mut max_rx_datagram_size = self
1193            .max_rx_datagram_size
1194            .unwrap_or(DEFAULT_UDP_MAX_RX_DATAGRAM_SIZE);
1195        let buffer_size = config.map(|c| c.buffer_size).unwrap_or(DEFAULT_BUFFER_SIZE);
1196        if u64::from(max_rx_datagram_size) > buffer_size {
1197            warn!(
1198                "UDP listener {}: max_rx_datagram_size = {} exceeds buffer_size = {}, clamping to buffer_size",
1199                self.address, max_rx_datagram_size, buffer_size
1200            );
1201            max_rx_datagram_size = buffer_size as u32;
1202        }
1203
1204        let max_flows = self.max_flows.unwrap_or(DEFAULT_UDP_MAX_FLOWS);
1205        if max_flows > 0
1206            && let Some(soft_limit) = soft_rlimit_nofile()
1207        {
1208            let advisory = soft_limit.saturating_mul(7) / 10;
1209            if u64::from(max_flows) > advisory {
1210                warn!(
1211                    "UDP listener {}: max_flows = {} exceeds ~70% of the soft RLIMIT_NOFILE ({}); \
1212                         per-flow connected sockets may hit EMFILE",
1213                    self.address, max_flows, advisory
1214                );
1215            }
1216        }
1217
1218        Ok(UdpListenerConfig {
1219            address: self.address.into(),
1220            public_address: self.public_address.map(|a| a.into()),
1221            front_timeout: self.front_timeout.unwrap_or(DEFAULT_UDP_FRONT_TIMEOUT),
1222            back_timeout: self.back_timeout.unwrap_or(DEFAULT_UDP_BACK_TIMEOUT),
1223            max_rx_datagram_size,
1224            max_flows,
1225            active: false,
1226        })
1227    }
1228}
1229
1230/// Read the soft `RLIMIT_NOFILE` (max open file descriptors). Used as an
1231/// advisory ceiling for `max_flows` on UDP listeners. Returns `None` when
1232/// the limit cannot be read so callers skip the advisory check rather than
1233/// failing config-load.
1234fn soft_rlimit_nofile() -> Option<u64> {
1235    let mut limit = libc::rlimit {
1236        rlim_cur: 0,
1237        rlim_max: 0,
1238    };
1239    // SAFETY: `getrlimit` writes into the provided `rlimit` out-parameter and
1240    // does not retain the pointer. A non-zero return means failure, in which
1241    // case we ignore the (uninitialised-by-contract) value and return None.
1242    let rc = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) };
1243    if rc == 0 {
1244        // `rlim_cur` is `rlim_t`, which is `u64` on the Tier-1 targets sozu
1245        // builds for, so it already matches the `Option<u64>` return type.
1246        Some(limit.rlim_cur)
1247    } else {
1248        None
1249    }
1250}
1251
1252/// read a custom HTTP answer from a file
1253fn read_http_answer_file(path: &Option<String>) -> Result<Option<String>, ConfigError> {
1254    match path {
1255        Some(path) => {
1256            let mut content = String::new();
1257            let mut file = File::open(path).map_err(|io_error| ConfigError::FileOpen {
1258                path_to_open: path.to_owned(),
1259                io_error,
1260            })?;
1261
1262            file.read_to_string(&mut content)
1263                .map_err(|io_error| ConfigError::FileRead {
1264                    path_to_read: path.to_owned(),
1265                    io_error,
1266                })?;
1267
1268            Ok(Some(content))
1269        }
1270        None => Ok(None),
1271    }
1272}
1273
1274/// Resolve a single `answers` map entry into the literal template body
1275/// the proto layer expects.
1276///
1277/// The same resolution rule applies to entries at every layer:
1278/// * **Listener-level** `[listeners.<id>.answers]` — the global default
1279///   that fires whenever no more specific override matches.
1280/// * **Cluster-level** `[clusters.<id>.answers]` — overrides the
1281///   listener-level default for the matching status code on requests
1282///   routed to that cluster.
1283///
1284/// Two source forms are accepted:
1285/// * **Filesystem path** — the value starts with the `file://` URI
1286///   scheme. Everything after the prefix is treated as a path; the
1287///   path is opened and read into a string. Mirrors the on-disk
1288///   loading the per-status [`read_http_answer_file`] helper performs
1289///   for the deprecated `answer_301`..`answer_507` fields.
1290/// * **Inline literal** (default) — anything else. The value is taken
1291///   verbatim as the template body, including an empty string (a
1292///   0-byte response payload, typical with `Connection: close` and no
1293///   headers). The bare-string default keeps the common case — a
1294///   short canned response — typing-light; operators who need a file
1295///   say so explicitly with `file://`.
1296pub fn resolve_answer_source(value: &str) -> Result<String, ConfigError> {
1297    if let Some(path) = value.strip_prefix("file://") {
1298        let mut content = String::new();
1299        let mut file = File::open(path).map_err(|io_error| ConfigError::FileOpen {
1300            path_to_open: path.to_owned(),
1301            io_error,
1302        })?;
1303        file.read_to_string(&mut content)
1304            .map_err(|io_error| ConfigError::FileRead {
1305                path_to_read: path.to_owned(),
1306                io_error,
1307            })?;
1308        return Ok(content);
1309    }
1310    Ok(value.to_owned())
1311}
1312
1313/// Load every per-status template referenced by `answers`.
1314///
1315/// `answers` maps an HTTP status code (e.g. `"503"`) to either a
1316/// filesystem path or an `inline:<body>` literal — see
1317/// [`resolve_answer_source`] for the resolution rules. Each entry is
1318/// resolved into a body string and inserted into the returned map
1319/// under the same key, ready to be assigned to the proto-level
1320/// `answers` field on a [`HttpListenerConfig`] / [`HttpsListenerConfig`]
1321/// / [`Cluster`]. Empty values are skipped (treated as "preserve
1322/// current") so the caller can use them as a no-op stub in example
1323/// configs.
1324///
1325/// Errors map to the existing `ConfigError::FileOpen` /
1326/// `ConfigError::FileRead` variants so the operator gets the same
1327/// diagnostics whether the path comes from this map or from the
1328/// deprecated per-status `answer_301`..`answer_507` fields.
1329pub fn load_answers(
1330    answers: &BTreeMap<String, String>,
1331) -> Result<BTreeMap<String, String>, ConfigError> {
1332    let mut out = BTreeMap::new();
1333    for (code, value) in answers {
1334        if value.is_empty() {
1335            continue;
1336        }
1337        out.insert(code.to_owned(), resolve_answer_source(value)?);
1338    }
1339    // POST: the loaded map never invents a status code (every output key is an
1340    // input key) and never grows past the input — empty-valued entries are
1341    // skipped, so |out| <= |answers|.
1342    debug_assert!(
1343        out.len() <= answers.len(),
1344        "load_answers must not synthesize entries"
1345    );
1346    debug_assert!(
1347        out.keys().all(|k| answers.contains_key(k)),
1348        "every loaded status code must come from the input map"
1349    );
1350    Ok(out)
1351}
1352
1353/// Cardinality knob for metrics labels in the StatsD network drain.
1354///
1355/// Mirrors HAProxy's `process|frontend|backend|server` extra-counters opt-in.
1356/// Operators choose the lowest level that satisfies their dashboards so that
1357/// the keyspace stays bounded. Each level is a SUPERSET of the previous one:
1358///
1359/// - `process` — proxy-only counters (no listener, cluster, or backend label).
1360/// - `frontend` — adds per-listener (frontend) breakdown.
1361/// - `cluster` — adds per-cluster aggregation. **Default** (preserves the
1362///   pre-knob behaviour).
1363/// - `backend` — adds per-backend aggregation (cluster + backend, highest
1364///   cardinality).
1365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
1366#[serde(rename_all = "lowercase")]
1367#[derive(Default)]
1368pub enum MetricDetailLevel {
1369    Process,
1370    Frontend,
1371    #[default]
1372    Cluster,
1373    Backend,
1374}
1375
1376impl From<MetricDetailLevel> for MetricDetail {
1377    fn from(level: MetricDetailLevel) -> Self {
1378        match level {
1379            MetricDetailLevel::Process => MetricDetail::DetailProcess,
1380            MetricDetailLevel::Frontend => MetricDetail::DetailFrontend,
1381            MetricDetailLevel::Cluster => MetricDetail::DetailCluster,
1382            MetricDetailLevel::Backend => MetricDetail::DetailBackend,
1383        }
1384    }
1385}
1386
1387impl From<MetricDetail> for MetricDetailLevel {
1388    /// Reverse of [`From<MetricDetailLevel> for MetricDetail`] — used by the
1389    /// worker side to convert the protobuf wire enum back into the
1390    /// configuration enum before passing it to `sozu_lib::metrics::setup`.
1391    fn from(detail: MetricDetail) -> Self {
1392        match detail {
1393            MetricDetail::DetailProcess => MetricDetailLevel::Process,
1394            MetricDetail::DetailFrontend => MetricDetailLevel::Frontend,
1395            MetricDetail::DetailCluster => MetricDetailLevel::Cluster,
1396            MetricDetail::DetailBackend => MetricDetailLevel::Backend,
1397        }
1398    }
1399}
1400
1401#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1402#[serde(deny_unknown_fields)]
1403pub struct MetricsConfig {
1404    pub address: SocketAddr,
1405    #[serde(default)]
1406    pub tagged_metrics: bool,
1407    #[serde(default)]
1408    pub prefix: Option<String>,
1409    /// Cardinality knob for label-aware metrics. Defaults to `cluster` to
1410    /// preserve historical behaviour. See [`MetricDetailLevel`].
1411    #[serde(default)]
1412    pub detail: MetricDetailLevel,
1413}
1414
1415#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1416#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
1417#[serde(deny_unknown_fields)]
1418pub enum PathRuleType {
1419    Prefix,
1420    Regex,
1421    Equals,
1422}
1423
1424#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1425#[serde(deny_unknown_fields)]
1426pub struct FileClusterFrontendConfig {
1427    pub address: SocketAddr,
1428    pub hostname: Option<String>,
1429    /// creates a path routing rule where the request URL path has to match this
1430    pub path: Option<String>,
1431    /// declares whether the path rule is Prefix (default), Regex, or Equals
1432    pub path_type: Option<PathRuleType>,
1433    pub method: Option<String>,
1434    pub certificate: Option<String>,
1435    pub key: Option<String>,
1436    pub certificate_chain: Option<String>,
1437    #[serde(default)]
1438    pub tls_versions: Vec<TlsVersion>,
1439    #[serde(default)]
1440    pub position: RulePosition,
1441    pub tags: Option<BTreeMap<String, String>>,
1442    /// Frontend-level redirect policy. Accepted values are `forward`
1443    /// (default — route to the backend), `permanent` (return 301 with the
1444    /// computed `Location`), or `unauthorized` (return 401 with
1445    /// `WWW-Authenticate: Basic realm=…`). Case-insensitive.
1446    pub redirect: Option<String>,
1447    /// Scheme used when emitting a permanent redirect's `Location`. Accepted
1448    /// values are `use-same` (default — preserve request scheme), `use-http`,
1449    /// `use-https`. Case-insensitive.
1450    pub redirect_scheme: Option<String>,
1451    /// Optional template applied to the emitted permanent-redirect response
1452    /// body. Supports the `%REDIRECT_LOCATION` and other variables
1453    /// documented in `doc/configure.md`.
1454    pub redirect_template: Option<String>,
1455    /// Rewrite host template. Supports `$HOST[n]` / `$PATH[n]` placeholders
1456    /// populated from regex captures collected during routing.
1457    pub rewrite_host: Option<String>,
1458    /// Rewrite path template. Same grammar as `rewrite_host`.
1459    pub rewrite_path: Option<String>,
1460    /// Optional literal port override on the rewritten URL.
1461    pub rewrite_port: Option<u32>,
1462    /// When true, requests routed through this frontend must carry a valid
1463    /// `Authorization: Basic <user:pass>` header whose hash matches one of
1464    /// the cluster's `authorized_hashes`. Default: false.
1465    pub required_auth: Option<bool>,
1466    /// Header mutations applied to requests and/or responses passing through
1467    /// this frontend. See [`HeaderEditConfig`] for the empty-value-deletes
1468    /// semantics (HAProxy `del-header` parity).
1469    pub headers: Option<Vec<HeaderEditConfig>>,
1470    /// Per-frontend HSTS (RFC 6797) policy. When set, overrides any
1471    /// listener-default HSTS for this frontend. Set `enabled = false`
1472    /// to suppress an inherited listener default. Per RFC 6797 §7.2,
1473    /// HSTS is rejected on plain-HTTP frontends at config-load time.
1474    pub hsts: Option<FileHstsConfig>,
1475}
1476
1477/// A single header mutation as serialised under
1478/// `[[clusters.<id>.frontends.headers]]`. Maps to the proto [`Header`]
1479/// message at request-build time.
1480///
1481/// `position` accepts `request`, `response`, or `both` (case-insensitive).
1482/// An empty `value` deletes the header by name (HAProxy `del-header` parity).
1483#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1484#[serde(deny_unknown_fields)]
1485pub struct HeaderEditConfig {
1486    pub position: String,
1487    pub key: String,
1488    pub value: String,
1489}
1490
1491/// HSTS (HTTP Strict Transport Security, RFC 6797) policy as serialised
1492/// under `[https.listeners.default.hsts]` (listener default) or
1493/// `[clusters.<id>.frontends.hsts]` (per-frontend override).
1494///
1495/// `enabled` is REQUIRED whenever the block is present — its presence vs
1496/// absence disambiguates "preserve current" / "explicit disable" / "enable"
1497/// on hot-reconfig partial updates.
1498///
1499/// When `enabled = true` and `max_age` is omitted, sozu substitutes
1500/// [`DEFAULT_HSTS_MAX_AGE`] (1 year) at config-load time.
1501#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1502#[serde(deny_unknown_fields)]
1503pub struct FileHstsConfig {
1504    /// REQUIRED. `true` enables HSTS for this scope; `false` suppresses
1505    /// any inherited listener default (explicit-disable signal).
1506    pub enabled: Option<bool>,
1507    /// `Strict-Transport-Security: max-age=<seconds>`. Optional —
1508    /// defaults to [`DEFAULT_HSTS_MAX_AGE`] when `enabled = true`.
1509    /// `max_age = 0` is the RFC 6797 §11.4 kill switch and is allowed
1510    /// silently; `0 < max_age < 86400` warns at config-load.
1511    pub max_age: Option<u32>,
1512    /// Append `; includeSubDomains` to the rendered header.
1513    pub include_subdomains: Option<bool>,
1514    /// Append `; preload` to the rendered header. Opt-in only — see RFC
1515    /// 6797 §14.2 and <https://hstspreload.org/>.
1516    pub preload: Option<bool>,
1517    /// Operator opt-in to override any backend-supplied
1518    /// `Strict-Transport-Security` header. RFC 6797 §6.1 default
1519    /// behaviour is to PRESERVE the backend's value (sozu's edit uses
1520    /// `HeaderEditMode::SetIfAbsent`). Set this to `true` to harden a
1521    /// stale or weak upstream HSTS policy centrally — the materialiser
1522    /// then uses `HeaderEditMode::Set`, replacing any backend STS with
1523    /// sozu's rendered value.
1524    pub force_replace_backend: Option<bool>,
1525}
1526
1527impl FileHstsConfig {
1528    /// Validate and convert the file-level [`FileHstsConfig`] into the
1529    /// proto [`HstsConfig`]. `scope` is a human-readable string (e.g.
1530    /// "listener" or "frontend api/example.com") surfaced into errors
1531    /// and warnings so the operator can pinpoint the offending block.
1532    ///
1533    /// Validation:
1534    /// - `enabled` is required when any other field is set
1535    ///   (`HstsEnabledRequired`); the parser returns the typed error so
1536    ///   callers can fail fast.
1537    /// - `enabled = true && max_age = None` substitutes
1538    ///   [`DEFAULT_HSTS_MAX_AGE`].
1539    /// - `0 < max_age < 86400` warns (likely misconfig — sub-day max-age
1540    ///   is useful only for testing).
1541    /// - `preload = true` with `max_age < DEFAULT_HSTS_MAX_AGE` or
1542    ///   `include_subdomains != Some(true)` warns (the Chrome HSTS
1543    ///   preload list will reject the host).
1544    /// - `max_age = 0` is allowed silently (RFC 6797 §11.4 kill switch).
1545    pub fn to_proto(&self, scope: &str) -> Result<HstsConfig, ConfigError> {
1546        let enabled = match self.enabled {
1547            Some(v) => v,
1548            None => return Err(ConfigError::HstsEnabledRequired(scope.to_owned())),
1549        };
1550
1551        let max_age = match (enabled, self.max_age) {
1552            (true, None) => Some(DEFAULT_HSTS_MAX_AGE),
1553            (_, m) => m,
1554        };
1555
1556        if let Some(value) = max_age
1557            && value > 0
1558            && value < 86_400
1559        {
1560            warn!(
1561                "HSTS max_age = {}s on {} is below 1 day — this is almost certainly a \
1562                 misconfiguration. RFC 6797 §11.4 reserves max_age = 0 as the explicit kill \
1563                 switch.",
1564                value, scope
1565            );
1566        }
1567
1568        let include_subdomains = self.include_subdomains;
1569        let preload = self.preload;
1570
1571        if matches!(preload, Some(true)) {
1572            let max_age_value = max_age.unwrap_or(0);
1573            if max_age_value < DEFAULT_HSTS_MAX_AGE {
1574                warn!(
1575                    "HSTS preload = true on {} with max_age = {}s; the Chrome HSTS preload \
1576                     list requires max_age >= {} (https://hstspreload.org/).",
1577                    scope, max_age_value, DEFAULT_HSTS_MAX_AGE
1578                );
1579            }
1580            if include_subdomains != Some(true) {
1581                warn!(
1582                    "HSTS preload = true on {} without include_subdomains = true; the Chrome \
1583                     HSTS preload list requires includeSubDomains \
1584                     (https://hstspreload.org/).",
1585                    scope
1586                );
1587            }
1588        }
1589
1590        let config = HstsConfig {
1591            enabled: Some(enabled),
1592            max_age,
1593            include_subdomains,
1594            preload,
1595            force_replace_backend: self.force_replace_backend,
1596        };
1597
1598        // POST: a built HstsConfig always records an explicit `enabled` flag
1599        // (the `None` case errored above), and an enabled policy always carries
1600        // a max_age — defaulted to DEFAULT_HSTS_MAX_AGE when the operator left
1601        // it unset, so the worker never emits an `max-age`-less STS header.
1602        debug_assert_eq!(
1603            config.enabled,
1604            Some(enabled),
1605            "built HSTS config must record the resolved enabled flag"
1606        );
1607        debug_assert!(
1608            !enabled || config.max_age.is_some(),
1609            "an enabled HSTS policy must carry a max_age"
1610        );
1611        Ok(config)
1612    }
1613}
1614
1615impl FileClusterFrontendConfig {
1616    pub fn to_tcp_front(&self) -> Result<TcpFrontendConfig, ConfigError> {
1617        if self.hostname.is_some() {
1618            return Err(ConfigError::InvalidFrontendConfig("hostname".to_string()));
1619        }
1620        if self.path.is_some() {
1621            return Err(ConfigError::InvalidFrontendConfig(
1622                "path_prefix".to_string(),
1623            ));
1624        }
1625        if self.certificate.is_some() {
1626            return Err(ConfigError::InvalidFrontendConfig(
1627                "certificate".to_string(),
1628            ));
1629        }
1630        if self.hostname.is_some() {
1631            return Err(ConfigError::InvalidFrontendConfig("hostname".to_string()));
1632        }
1633        if self.certificate_chain.is_some() {
1634            return Err(ConfigError::InvalidFrontendConfig(
1635                "certificate_chain".to_string(),
1636            ));
1637        }
1638
1639        let tcp_front = TcpFrontendConfig {
1640            address: self.address,
1641            tags: self.tags.clone(),
1642            // Resolved against `known_addresses` in `populate_clusters`; a
1643            // bare `to_tcp_front` (no listener context) defaults to TCP.
1644            udp: false,
1645        };
1646        // POST: a TCP frontend binds exactly the requested address and carries
1647        // no HTTP-only attributes — the guards above reject hostname / path /
1648        // certificate, so an Ok return is a witness that none leaked through
1649        // (an L7 attribute on an L4 frontend is a config-shape violation).
1650        debug_assert_eq!(
1651            tcp_front.address, self.address,
1652            "TCP frontend must bind the requested address"
1653        );
1654        debug_assert!(
1655            self.hostname.is_none()
1656                && self.path.is_none()
1657                && self.certificate.is_none()
1658                && self.certificate_chain.is_none(),
1659            "a built TCP frontend must carry no HTTP-only attributes"
1660        );
1661        Ok(tcp_front)
1662    }
1663
1664    pub fn to_http_front(&self, _cluster_id: &str) -> Result<HttpFrontendConfig, ConfigError> {
1665        let hostname = match &self.hostname {
1666            Some(hostname) => hostname.to_owned(),
1667            None => {
1668                return Err(ConfigError::Missing(MissingKind::Field(
1669                    "hostname".to_string(),
1670                )));
1671            }
1672        };
1673
1674        let key_opt = match self.key.as_ref() {
1675            None => None,
1676            Some(path) => {
1677                let key = Config::load_file(path)?;
1678                Some(key)
1679            }
1680        };
1681
1682        let certificate_opt = match self.certificate.as_ref() {
1683            None => None,
1684            Some(path) => {
1685                let certificate = Config::load_file(path)?;
1686                Some(certificate)
1687            }
1688        };
1689
1690        let certificate_chain = match self.certificate_chain.as_ref() {
1691            None => None,
1692            Some(path) => {
1693                let certificate_chain = Config::load_file(path)?;
1694                Some(split_certificate_chain(certificate_chain))
1695            }
1696        };
1697
1698        let path = match (self.path.as_ref(), self.path_type.as_ref()) {
1699            (None, _) => PathRule::prefix("".to_string()),
1700            (Some(s), Some(PathRuleType::Prefix)) => PathRule::prefix(s.to_string()),
1701            (Some(s), Some(PathRuleType::Regex)) => PathRule::regex(s.to_string()),
1702            (Some(s), Some(PathRuleType::Equals)) => PathRule::equals(s.to_string()),
1703            (Some(s), None) => PathRule::prefix(s.clone()),
1704        };
1705
1706        let redirect = match self.redirect.as_deref() {
1707            Some(v) => Some(parse_redirect_policy(v)?),
1708            None => None,
1709        };
1710        let redirect_scheme = match self.redirect_scheme.as_deref() {
1711            Some(v) => Some(parse_redirect_scheme(v)?),
1712            None => None,
1713        };
1714
1715        let headers = match self.headers.as_ref() {
1716            Some(entries) => {
1717                let mut out = Vec::with_capacity(entries.len());
1718                for (index, entry) in entries.iter().enumerate() {
1719                    out.push(parse_header_edit(index, entry)?);
1720                }
1721                out
1722            }
1723            None => Vec::new(),
1724        };
1725
1726        // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on
1727        // plaintext-HTTP responses. A frontend without a key+certificate
1728        // pair generates `RequestType::AddHttpFrontend` in
1729        // `HttpFrontendConfig::generate_requests`, so HSTS configured
1730        // there would silently target an HTTP frontend. Reject at
1731        // config-load before the cert-presence branch can consume it.
1732        let frontend_serves_https = key_opt.is_some() && certificate_opt.is_some();
1733        let hsts = match self.hsts.as_ref() {
1734            Some(h) => {
1735                if !frontend_serves_https {
1736                    return Err(ConfigError::HstsOnPlainHttp(format!(
1737                        "frontend {_cluster_id}/{hostname}"
1738                    )));
1739                }
1740                Some(h.to_proto(&format!("frontend {_cluster_id}/{hostname}"))?)
1741            }
1742            None => None,
1743        };
1744
1745        Ok(HttpFrontendConfig {
1746            address: self.address,
1747            hostname,
1748            certificate: certificate_opt,
1749            key: key_opt,
1750            certificate_chain,
1751            tls_versions: self.tls_versions.clone(),
1752            position: self.position,
1753            path,
1754            method: self.method.clone(),
1755            tags: self.tags.clone(),
1756            redirect,
1757            redirect_scheme,
1758            redirect_template: self.redirect_template.clone(),
1759            rewrite_host: self.rewrite_host.clone(),
1760            rewrite_path: self.rewrite_path.clone(),
1761            rewrite_port: self.rewrite_port,
1762            required_auth: self.required_auth,
1763            headers,
1764            hsts,
1765        })
1766    }
1767}
1768
1769/// Parse a `redirect` TOML value (case-insensitive) into the proto enum.
1770pub(crate) fn parse_redirect_policy(value: &str) -> Result<RedirectPolicy, ConfigError> {
1771    match value.to_ascii_lowercase().as_str() {
1772        "forward" => Ok(RedirectPolicy::Forward),
1773        "permanent" => Ok(RedirectPolicy::Permanent),
1774        "unauthorized" => Ok(RedirectPolicy::Unauthorized),
1775        _ => Err(ConfigError::InvalidRedirectPolicy(value.to_owned())),
1776    }
1777}
1778
1779/// Parse a `redirect_scheme` TOML value (case-insensitive) into the proto enum.
1780pub(crate) fn parse_redirect_scheme(value: &str) -> Result<RedirectScheme, ConfigError> {
1781    match value.to_ascii_lowercase().as_str() {
1782        "use-same" | "use_same" => Ok(RedirectScheme::UseSame),
1783        "use-http" | "use_http" => Ok(RedirectScheme::UseHttp),
1784        "use-https" | "use_https" => Ok(RedirectScheme::UseHttps),
1785        _ => Err(ConfigError::InvalidRedirectScheme(value.to_owned())),
1786    }
1787}
1788
1789/// Parse a `[[clusters.<id>.frontends.headers]]` entry into the proto
1790/// [`Header`] message. `index` is the zero-based position of `entry` in
1791/// the source array — surfaced into the error so a multi-entry config
1792/// pinpoints the bad row instead of just naming the unknown position.
1793/// An empty `value` is the HAProxy `del-header` parity (deletes the
1794/// header by name); the proto carries the empty string verbatim.
1795pub(crate) fn parse_header_edit(
1796    index: usize,
1797    entry: &HeaderEditConfig,
1798) -> Result<Header, ConfigError> {
1799    let position = match entry.position.to_ascii_lowercase().as_str() {
1800        "request" => HeaderPosition::Request,
1801        "response" => HeaderPosition::Response,
1802        "both" => HeaderPosition::Both,
1803        _ => {
1804            return Err(ConfigError::InvalidHeaderPosition {
1805                index,
1806                position: entry.position.clone(),
1807            });
1808        }
1809    };
1810    if !header_name_is_valid_token(entry.key.as_bytes()) {
1811        return Err(ConfigError::InvalidHeaderBytes {
1812            index,
1813            field: "key",
1814        });
1815    }
1816    if header_value_contains_forbidden_controls(entry.value.as_bytes()) {
1817        return Err(ConfigError::InvalidHeaderBytes {
1818            index,
1819            field: "value",
1820        });
1821    }
1822    let header = Header {
1823        position: position as i32,
1824        key: entry.key.clone(),
1825        val: entry.value.clone(),
1826    };
1827    // POST: a Header that escapes this function carries a key that is a valid
1828    // RFC 9110 token and a value free of the forbidden control bytes — the two
1829    // guards above are the sole gate, so an emitted Header can never inject a
1830    // CRLF or a bad token onto the H1 wire (mirrors the runtime filter in
1831    // mux/converter.rs).
1832    debug_assert!(
1833        header_name_is_valid_token(header.key.as_bytes()),
1834        "an emitted header key must be a valid token"
1835    );
1836    debug_assert!(
1837        !header_value_contains_forbidden_controls(header.val.as_bytes()),
1838        "an emitted header value must be free of forbidden control bytes"
1839    );
1840    Ok(header)
1841}
1842
1843/// Field names follow the RFC 9110 §5.1 `token` grammar: non-empty,
1844/// composed of `tchar` bytes (alphanumeric plus a closed punctuation
1845/// list). HTAB and SP are NOT tchar — they belong to field-value
1846/// grammar and must be rejected in the name. Reusing the more
1847/// permissive value-side filter would let `Host\t` slip through and
1848/// produce an invalid header line on the H1 wire (security review
1849/// LISA-002 follow-up).
1850pub(crate) fn header_name_is_valid_token(bytes: &[u8]) -> bool {
1851    if bytes.is_empty() {
1852        return false;
1853    }
1854    bytes.iter().all(|&b| is_tchar(b))
1855}
1856
1857/// `tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
1858/// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA` per RFC 9110 §5.6.2.
1859fn is_tchar(b: u8) -> bool {
1860    b.is_ascii_alphanumeric()
1861        || matches!(
1862            b,
1863            b'!' | b'#'
1864                | b'$'
1865                | b'%'
1866                | b'&'
1867                | b'\''
1868                | b'*'
1869                | b'+'
1870                | b'-'
1871                | b'.'
1872                | b'^'
1873                | b'_'
1874                | b'`'
1875                | b'|'
1876                | b'~'
1877        )
1878}
1879
1880/// Reject any byte that would let a header injection escape the value
1881/// block on the wire (RFC 9110 §5.5 / RFC 9113 §8.2.1):
1882/// `\0..=\x08`, `\x0A..=\x1F`, and `\x7F` — the entire C0 control set
1883/// minus horizontal tab `\x09`, which RFC 9110 explicitly permits in
1884/// field values. Mirrors the runtime filter at
1885/// `lib/src/protocol/mux/converter.rs::call` so config-load and runtime
1886/// agree on which header values may travel.
1887pub(crate) fn header_value_contains_forbidden_controls(bytes: &[u8]) -> bool {
1888    bytes
1889        .iter()
1890        .any(|&b| matches!(b, 0x00..=0x08 | 0x0A..=0x1F | 0x7F))
1891}
1892
1893#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1894#[serde(deny_unknown_fields, rename_all = "lowercase")]
1895pub enum ListenerProtocol {
1896    Http,
1897    Https,
1898    Tcp,
1899    Udp,
1900}
1901
1902#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1903#[serde(deny_unknown_fields, rename_all = "lowercase")]
1904pub enum FileClusterProtocolConfig {
1905    Http,
1906    Tcp,
1907}
1908
1909fn default_health_check_interval() -> u32 {
1910    10
1911}
1912fn default_health_check_timeout() -> u32 {
1913    5
1914}
1915fn default_health_check_threshold() -> u32 {
1916    3
1917}
1918
1919#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1920#[serde(deny_unknown_fields)]
1921pub struct FileHealthCheckConfig {
1922    pub uri: String,
1923    #[serde(default = "default_health_check_interval")]
1924    pub interval: u32,
1925    #[serde(default = "default_health_check_timeout")]
1926    pub timeout: u32,
1927    #[serde(default = "default_health_check_threshold")]
1928    pub healthy_threshold: u32,
1929    #[serde(default = "default_health_check_threshold")]
1930    pub unhealthy_threshold: u32,
1931    #[serde(default)]
1932    pub expected_status: u32,
1933}
1934
1935impl FileHealthCheckConfig {
1936    pub fn to_proto(&self) -> HealthCheckConfig {
1937        let proto = HealthCheckConfig {
1938            uri: self.uri.to_owned(),
1939            interval: self.interval,
1940            timeout: self.timeout,
1941            healthy_threshold: self.healthy_threshold,
1942            unhealthy_threshold: self.unhealthy_threshold,
1943            expected_status: self.expected_status,
1944        };
1945        // POST: the proto mirrors the file config exactly — the URI and all
1946        // timing knobs are carried through verbatim (no clamping or defaulting
1947        // happens at this layer; defaults are applied by serde at parse time).
1948        debug_assert_eq!(proto.uri, self.uri, "proto URI must mirror the file config");
1949        debug_assert!(
1950            proto.interval == self.interval
1951                && proto.timeout == self.timeout
1952                && proto.healthy_threshold == self.healthy_threshold
1953                && proto.unhealthy_threshold == self.unhealthy_threshold,
1954            "proto timing knobs must mirror the file config"
1955        );
1956        proto
1957    }
1958}
1959
1960/// Validate a [`HealthCheckConfig`] for the rules every layer relies on:
1961/// strict positive thresholds and a URI that cannot smuggle a second
1962/// HTTP message on the wire (RFC 9110 §5.1 — request-target). Used by
1963/// the CLI request builder and the worker `SetHealthCheck` handler so
1964/// off-channel inputs (TOML reload, third-party clients) are
1965/// constrained the same way as `sozu cluster health-check set`.
1966///
1967/// The function is intentionally [`Result<(), &'static str>`] rather
1968/// than carrying a structured error: the diagnostics only flow into
1969/// CLI output / worker error responses where the message is the value.
1970pub fn validate_health_check_config(cfg: &HealthCheckConfig) -> Result<(), &'static str> {
1971    if cfg.interval == 0 {
1972        return Err("health check interval must be > 0");
1973    }
1974    if cfg.timeout == 0 {
1975        return Err("health check timeout must be > 0");
1976    }
1977    if cfg.healthy_threshold == 0 {
1978        return Err("health check healthy_threshold must be > 0");
1979    }
1980    if cfg.unhealthy_threshold == 0 {
1981        return Err("health check unhealthy_threshold must be > 0");
1982    }
1983    if !cfg.uri.starts_with('/') {
1984        return Err("health check URI must start with '/'");
1985    }
1986    if cfg
1987        .uri
1988        .bytes()
1989        .any(|b| b == b'\r' || b == b'\n' || b == 0 || (b < 0x20 && b != b'\t'))
1990    {
1991        return Err("health check URI must not contain CR, LF, NUL, or other C0 control bytes");
1992    }
1993    // POST: a validated config has strictly-positive timing knobs (a zero
1994    // interval/timeout/threshold would make the health-check loop spin or
1995    // never converge) and a request-target the worker can splice into an HTTP
1996    // probe without smuggling a second message. Every Ok return is a witness
1997    // for all of these.
1998    debug_assert!(
1999        cfg.interval > 0
2000            && cfg.timeout > 0
2001            && cfg.healthy_threshold > 0
2002            && cfg.unhealthy_threshold > 0,
2003        "validated health-check thresholds must all be strictly positive"
2004    );
2005    debug_assert!(
2006        cfg.uri.starts_with('/'),
2007        "validated health-check URI must be an absolute path"
2008    );
2009    Ok(())
2010}
2011
2012#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2013#[serde(deny_unknown_fields)]
2014pub struct FileClusterConfig {
2015    pub frontends: Vec<FileClusterFrontendConfig>,
2016    pub backends: Vec<BackendConfig>,
2017    pub protocol: FileClusterProtocolConfig,
2018    pub sticky_session: Option<bool>,
2019    pub https_redirect: Option<bool>,
2020    #[serde(default)]
2021    pub send_proxy: Option<bool>,
2022    #[serde(default)]
2023    pub load_balancing: LoadBalancingAlgorithms,
2024    pub answer_503: Option<String>,
2025    #[serde(default)]
2026    pub load_metric: Option<LoadMetric>,
2027    /// Backend-capability hint: `true` when the backend speaks HTTP/2 (h2c or h2+TLS once #1218 lands).
2028    /// Does NOT gate H2 at the frontend — frontend H2 is ALPN-negotiated independently (see `alpn_protocols`).
2029    pub http2: Option<bool>,
2030    /// Per-cluster HTTP answer template overrides keyed by HTTP status
2031    /// code (e.g. `"503"`). Each value is either a filesystem path or an
2032    /// `inline:<body>` literal — see [`resolve_answer_source`]. Loaded
2033    /// into [`Cluster::answers`] at build time via [`load_answers`].
2034    ///
2035    /// Layering: an entry here overrides the listener-level
2036    /// `[listeners.<id>.answers]` default for the matching status on
2037    /// requests routed to this cluster. The listener-level map is the
2038    /// global default; the cluster-level map is the per-cluster
2039    /// override.
2040    pub answers: Option<BTreeMap<String, String>>,
2041    /// Optional explicit port to use when building the `Location` header
2042    /// for an `https_redirect`. When unset, the listener's effective HTTPS
2043    /// port is used. Lets operators front a non-standard HTTPS port (e.g.
2044    /// 8443) on the redirect target while keeping `https_redirect = true`.
2045    pub https_redirect_port: Option<u32>,
2046    /// Authorized credentials for HTTP basic authentication, formatted as
2047    /// `username:hex(sha256(password))` (lower-case hex). Empty list
2048    /// disables auth even when a frontend sets `required_auth = true` —
2049    /// such requests are rejected with 401.
2050    pub authorized_hashes: Option<Vec<String>>,
2051    /// Realm string emitted in `WWW-Authenticate: Basic realm="…"` when
2052    /// an unauthenticated request is rejected. Treated as an opaque
2053    /// value (no template substitution).
2054    pub www_authenticate: Option<String>,
2055    /// Override the global per-(cluster, source-IP) connection limit for
2056    /// this cluster. `None` (field absent) inherits the global default
2057    /// `max_connections_per_ip`. `Some(0)` is explicit "unlimited for
2058    /// this cluster". `Some(n > 0)` overrides with the cluster-specific
2059    /// limit. The source IP is taken from the parsed proxy-protocol
2060    /// header when present, else `peer_addr`.
2061    pub max_connections_per_ip: Option<u64>,
2062    /// Override the global `Retry-After` header value (seconds) emitted
2063    /// on HTTP 429 responses for this cluster. `None` inherits the global
2064    /// default. `Some(0)` omits the header. TCP clusters carry this
2065    /// field for shape uniformity but never emit the header (no HTTP
2066    /// envelope).
2067    pub retry_after: Option<u32>,
2068    /// Optional HTTP health-check configuration. The probe wire format
2069    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
2070    /// (h2c) when true.
2071    #[serde(default)]
2072    pub health_check: Option<FileHealthCheckConfig>,
2073    /// Optional UDP-specific cluster configuration, parsed from a
2074    /// `[clusters.<id>.udp]` block. Additive: clusters without a `udp`
2075    /// block produce `udp: None` on the resulting [`Cluster`].
2076    #[serde(default)]
2077    pub udp: Option<FileUdpClusterConfig>,
2078}
2079
2080/// UDP backend health-check configuration, parsed from
2081/// `[clusters.<id>.udp.health]`.
2082#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2083#[serde(deny_unknown_fields)]
2084pub struct FileUdpHealthConfig {
2085    /// probe mode, parsed in SCREAMING_SNAKE_CASE: `"HEALTH_OFF"`,
2086    /// `"TCP_PROBE"`, or `"UDP_PROBE"`. Defaults to `TCP_PROBE` when a
2087    /// `udp.health` block is present.
2088    pub mode: Option<UdpHealthMode>,
2089    pub tcp_port: Option<u32>,
2090    pub rise: Option<u32>,
2091    pub fall: Option<u32>,
2092    pub fail_open: Option<bool>,
2093    /// hex-free literal payload sent for a UDP probe.
2094    pub udp_probe_payload: Option<String>,
2095    pub probe_interval_seconds: Option<u32>,
2096    pub probe_timeout_seconds: Option<u32>,
2097}
2098
2099impl FileUdpHealthConfig {
2100    pub fn to_proto(&self) -> UdpHealthConfig {
2101        UdpHealthConfig {
2102            mode: self.mode.map(|m| m as i32),
2103            tcp_port: self.tcp_port,
2104            rise: self.rise,
2105            fall: self.fall,
2106            fail_open: self.fail_open,
2107            udp_probe_payload: self
2108                .udp_probe_payload
2109                .as_ref()
2110                .map(|p| p.as_bytes().to_owned()),
2111            probe_interval_seconds: self.probe_interval_seconds,
2112            probe_timeout_seconds: self.probe_timeout_seconds,
2113        }
2114    }
2115}
2116
2117/// UDP-specific cluster knobs, parsed from a `[clusters.<id>.udp]` block.
2118#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2119#[serde(deny_unknown_fields)]
2120pub struct FileUdpClusterConfig {
2121    /// flow affinity key, parsed in SCREAMING_SNAKE_CASE: `"SOURCE_IP"`
2122    /// (default) or `"SOURCE_IP_PORT"`.
2123    pub affinity_key: Option<UdpAffinityKey>,
2124    /// expected replies per flow; 0 = unlimited.
2125    pub responses: Option<u32>,
2126    /// max client datagrams per flow; 0 = unlimited.
2127    pub requests: Option<u32>,
2128    /// send a PROXY protocol v2 header to the backend.
2129    pub send_proxy_protocol: Option<bool>,
2130    /// prepend PPv2 to every datagram; false = first-datagram only.
2131    pub proxy_protocol_every_datagram: Option<bool>,
2132    /// optional backend health-check configuration.
2133    pub health: Option<FileUdpHealthConfig>,
2134}
2135
2136impl FileUdpClusterConfig {
2137    pub fn to_proto(&self) -> UdpClusterConfig {
2138        UdpClusterConfig {
2139            affinity_key: self.affinity_key.map(|k| k as i32),
2140            responses: self.responses,
2141            requests: self.requests,
2142            send_proxy_protocol: self.send_proxy_protocol,
2143            proxy_protocol_every_datagram: self.proxy_protocol_every_datagram,
2144            health: self.health.as_ref().map(|h| h.to_proto()),
2145        }
2146    }
2147}
2148
2149#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2150#[serde(deny_unknown_fields)]
2151pub struct BackendConfig {
2152    pub address: SocketAddr,
2153    pub weight: Option<u8>,
2154    pub sticky_id: Option<String>,
2155    pub backup: Option<bool>,
2156    pub backend_id: Option<String>,
2157}
2158
2159impl FileClusterConfig {
2160    pub fn to_cluster_config(
2161        self,
2162        cluster_id: &str,
2163        expect_proxy: &HashSet<SocketAddr>,
2164    ) -> Result<ClusterConfig, ConfigError> {
2165        // PRE: every frontend that converts cleanly must survive into the built
2166        // cluster — no frontend is silently dropped during conversion.
2167        let requested_frontend_count = self.frontends.len();
2168        match self.protocol {
2169            FileClusterProtocolConfig::Tcp => {
2170                let mut has_expect_proxy = None;
2171                let mut frontends = Vec::new();
2172                for f in self.frontends {
2173                    if expect_proxy.contains(&f.address) {
2174                        match has_expect_proxy {
2175                            Some(true) => {}
2176                            Some(false) => {
2177                                return Err(ConfigError::Incompatible {
2178                                    object: ObjectKind::Cluster,
2179                                    id: cluster_id.to_owned(),
2180                                    kind: IncompatibilityKind::ProxyProtocol,
2181                                });
2182                            }
2183                            None => has_expect_proxy = Some(true),
2184                        }
2185                    } else {
2186                        match has_expect_proxy {
2187                            Some(false) => {}
2188                            Some(true) => {
2189                                return Err(ConfigError::Incompatible {
2190                                    object: ObjectKind::Cluster,
2191                                    id: cluster_id.to_owned(),
2192                                    kind: IncompatibilityKind::ProxyProtocol,
2193                                });
2194                            }
2195                            None => has_expect_proxy = Some(false),
2196                        }
2197                    }
2198                    let tcp_frontend = f.to_tcp_front()?;
2199                    frontends.push(tcp_frontend);
2200                }
2201
2202                let send_proxy = self.send_proxy.unwrap_or(false);
2203                let expect_proxy = has_expect_proxy.unwrap_or(false);
2204                let proxy_protocol = match (send_proxy, expect_proxy) {
2205                    (true, true) => Some(ProxyProtocolConfig::RelayHeader),
2206                    (true, false) => Some(ProxyProtocolConfig::SendHeader),
2207                    (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
2208                    _ => None,
2209                };
2210
2211                let answers = match self.answers.as_ref() {
2212                    Some(map) => load_answers(map)?,
2213                    None => BTreeMap::new(),
2214                };
2215
2216                let udp = self.udp.as_ref().map(|u| u.to_proto());
2217                // POST: every requested frontend converted (none dropped), and
2218                // the resolved proxy-protocol mode is the documented function of
2219                // the (send, expect) pair — expect-only must never resolve to a
2220                // send-header mode and vice versa, which would corrupt the wire
2221                // framing.
2222                debug_assert_eq!(
2223                    frontends.len(),
2224                    requested_frontend_count,
2225                    "every TCP frontend must survive conversion"
2226                );
2227                debug_assert_eq!(
2228                    proxy_protocol,
2229                    match (send_proxy, expect_proxy) {
2230                        (true, true) => Some(ProxyProtocolConfig::RelayHeader),
2231                        (true, false) => Some(ProxyProtocolConfig::SendHeader),
2232                        (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
2233                        (false, false) => None,
2234                    },
2235                    "proxy_protocol must be the (send, expect) function"
2236                );
2237
2238                Ok(ClusterConfig::Tcp(TcpClusterConfig {
2239                    cluster_id: cluster_id.to_string(),
2240                    frontends,
2241                    backends: self.backends,
2242                    proxy_protocol,
2243                    load_balancing: self.load_balancing,
2244                    load_metric: self.load_metric,
2245                    answers,
2246                    https_redirect_port: self.https_redirect_port,
2247                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
2248                    www_authenticate: self.www_authenticate,
2249                    max_connections_per_ip: self.max_connections_per_ip,
2250                    retry_after: self.retry_after,
2251                    health_check: self.health_check.as_ref().map(|hc| hc.to_proto()),
2252                    udp,
2253                }))
2254            }
2255            FileClusterProtocolConfig::Http => {
2256                let mut frontends = Vec::new();
2257                for frontend in self.frontends {
2258                    let http_frontend = frontend.to_http_front(cluster_id)?;
2259                    frontends.push(http_frontend);
2260                }
2261
2262                let answer_503 = self.answer_503.as_ref().and_then(|path| {
2263                    Config::load_file(path)
2264                        .map_err(|e| {
2265                            error!("cannot load 503 error page at path '{}': {:?}", path, e);
2266                            e
2267                        })
2268                        .ok()
2269                });
2270
2271                let answers = match self.answers.as_ref() {
2272                    Some(map) => load_answers(map)?,
2273                    None => BTreeMap::new(),
2274                };
2275
2276                let udp = self.udp.as_ref().map(|u| u.to_proto());
2277                // POST: every requested HTTP frontend converted — none dropped
2278                // (a dropped frontend would silently stop routing a hostname).
2279                debug_assert_eq!(
2280                    frontends.len(),
2281                    requested_frontend_count,
2282                    "every HTTP frontend must survive conversion"
2283                );
2284
2285                Ok(ClusterConfig::Http(HttpClusterConfig {
2286                    cluster_id: cluster_id.to_string(),
2287                    frontends,
2288                    backends: self.backends,
2289                    sticky_session: self.sticky_session.unwrap_or(false),
2290                    https_redirect: self.https_redirect.unwrap_or(false),
2291                    load_balancing: self.load_balancing,
2292                    load_metric: self.load_metric,
2293                    answer_503,
2294                    http2: self.http2,
2295                    answers,
2296                    https_redirect_port: self.https_redirect_port,
2297                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
2298                    www_authenticate: self.www_authenticate,
2299                    max_connections_per_ip: self.max_connections_per_ip,
2300                    retry_after: self.retry_after,
2301                    health_check: self.health_check.as_ref().map(|hc| hc.to_proto()),
2302                    udp,
2303                }))
2304            }
2305        }
2306    }
2307}
2308
2309#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2310#[serde(deny_unknown_fields)]
2311pub struct HttpFrontendConfig {
2312    pub address: SocketAddr,
2313    pub hostname: String,
2314    pub path: PathRule,
2315    pub method: Option<String>,
2316    pub certificate: Option<String>,
2317    pub key: Option<String>,
2318    pub certificate_chain: Option<Vec<String>>,
2319    #[serde(default)]
2320    pub tls_versions: Vec<TlsVersion>,
2321    #[serde(default)]
2322    pub position: RulePosition,
2323    pub tags: Option<BTreeMap<String, String>>,
2324    /// Resolved redirect policy. `None` keeps the proto-default `FORWARD`.
2325    #[serde(default)]
2326    pub redirect: Option<RedirectPolicy>,
2327    /// Resolved redirect scheme. `None` keeps the proto-default `USE_SAME`.
2328    #[serde(default)]
2329    pub redirect_scheme: Option<RedirectScheme>,
2330    #[serde(default)]
2331    pub redirect_template: Option<String>,
2332    #[serde(default)]
2333    pub rewrite_host: Option<String>,
2334    #[serde(default)]
2335    pub rewrite_path: Option<String>,
2336    #[serde(default)]
2337    pub rewrite_port: Option<u32>,
2338    #[serde(default)]
2339    pub required_auth: Option<bool>,
2340    /// Header mutations applied to requests and/or responses passing through
2341    /// this frontend. Empty by default.
2342    #[serde(default)]
2343    pub headers: Vec<Header>,
2344    /// Resolved per-frontend HSTS (RFC 6797) policy. `None` means inherit
2345    /// the listener default at frontend-add time in the worker.
2346    #[serde(default)]
2347    pub hsts: Option<HstsConfig>,
2348}
2349
2350impl HttpFrontendConfig {
2351    pub fn generate_requests(&self, cluster_id: &str) -> Vec<Request> {
2352        let mut v = Vec::new();
2353
2354        let tags = self.tags.clone().unwrap_or_default();
2355
2356        if self.key.is_some() && self.certificate.is_some() {
2357            v.push(
2358                RequestType::AddCertificate(AddCertificate {
2359                    address: self.address.into(),
2360                    certificate: CertificateAndKey {
2361                        key: self.key.clone().unwrap(),
2362                        certificate: self.certificate.clone().unwrap(),
2363                        certificate_chain: self.certificate_chain.clone().unwrap_or_default(),
2364                        versions: self.tls_versions.iter().map(|v| *v as i32).collect(),
2365                        // This field is used to override the certificate subject and san, we should not set it when
2366                        // loading the configuration, as we may provide a wildcard certificate for a specific domain.
2367                        // As a result, we will reject legit traffic for others domains as the certificate resolver will
2368                        // not load twice the same certificate and then do not register the certificate for others domains.
2369                        names: vec![],
2370                    },
2371                    expired_at: None,
2372                })
2373                .into(),
2374            );
2375
2376            v.push(
2377                RequestType::AddHttpsFrontend(RequestHttpFrontend {
2378                    cluster_id: Some(cluster_id.to_string()),
2379                    address: self.address.into(),
2380                    hostname: self.hostname.clone(),
2381                    path: self.path.clone(),
2382                    method: self.method.clone(),
2383                    position: self.position.into(),
2384                    tags,
2385                    redirect: self.redirect.map(|r| r as i32),
2386                    required_auth: self.required_auth,
2387                    redirect_scheme: self.redirect_scheme.map(|s| s as i32),
2388                    redirect_template: self.redirect_template.clone(),
2389                    rewrite_host: self.rewrite_host.clone(),
2390                    rewrite_path: self.rewrite_path.clone(),
2391                    rewrite_port: self.rewrite_port,
2392                    headers: self.headers.clone(),
2393                    hsts: self.hsts,
2394                })
2395                .into(),
2396            );
2397        } else {
2398            //create the front both for HTTP and HTTPS if possible
2399            v.push(
2400                RequestType::AddHttpFrontend(RequestHttpFrontend {
2401                    cluster_id: Some(cluster_id.to_string()),
2402                    address: self.address.into(),
2403                    hostname: self.hostname.clone(),
2404                    path: self.path.clone(),
2405                    method: self.method.clone(),
2406                    position: self.position.into(),
2407                    tags,
2408                    redirect: self.redirect.map(|r| r as i32),
2409                    required_auth: self.required_auth,
2410                    redirect_scheme: self.redirect_scheme.map(|s| s as i32),
2411                    redirect_template: self.redirect_template.clone(),
2412                    rewrite_host: self.rewrite_host.clone(),
2413                    rewrite_path: self.rewrite_path.clone(),
2414                    rewrite_port: self.rewrite_port,
2415                    headers: self.headers.clone(),
2416                    hsts: self.hsts,
2417                })
2418                .into(),
2419            );
2420        }
2421
2422        v
2423    }
2424}
2425
2426#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2427#[serde(deny_unknown_fields)]
2428pub struct HttpClusterConfig {
2429    pub cluster_id: String,
2430    pub frontends: Vec<HttpFrontendConfig>,
2431    pub backends: Vec<BackendConfig>,
2432    pub sticky_session: bool,
2433    pub https_redirect: bool,
2434    pub load_balancing: LoadBalancingAlgorithms,
2435    pub load_metric: Option<LoadMetric>,
2436    pub answer_503: Option<String>,
2437    pub http2: Option<bool>,
2438    /// Per-status template body map (already loaded from disk). Maps to
2439    /// the proto [`Cluster::answers`] field.
2440    #[serde(default)]
2441    pub answers: BTreeMap<String, String>,
2442    #[serde(default)]
2443    pub https_redirect_port: Option<u32>,
2444    #[serde(default)]
2445    pub authorized_hashes: Vec<String>,
2446    #[serde(default)]
2447    pub www_authenticate: Option<String>,
2448    /// Per-cluster override of the global `max_connections_per_ip`. See
2449    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
2450    #[serde(default)]
2451    pub max_connections_per_ip: Option<u64>,
2452    /// Per-cluster override of the global `retry_after` HTTP-429 header
2453    /// value (seconds). See [`FileClusterConfig::retry_after`].
2454    #[serde(default)]
2455    pub retry_after: Option<u32>,
2456    /// Optional HTTP health-check configuration. The probe wire format
2457    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
2458    /// (h2c) when true.
2459    #[serde(default)]
2460    pub health_check: Option<HealthCheckConfig>,
2461    /// Optional UDP-specific cluster configuration. Always `None` for HTTP
2462    /// clusters; carried for shape uniformity with the proto [`Cluster`].
2463    #[serde(default)]
2464    pub udp: Option<UdpClusterConfig>,
2465}
2466
2467impl HttpClusterConfig {
2468    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
2469        let mut v: Vec<Request> = vec![
2470            RequestType::AddCluster(Cluster {
2471                cluster_id: self.cluster_id.clone(),
2472                sticky_session: self.sticky_session,
2473                https_redirect: self.https_redirect,
2474                proxy_protocol: None,
2475                load_balancing: self.load_balancing as i32,
2476                answer_503: self.answer_503.clone(),
2477                load_metric: self.load_metric.map(|s| s as i32),
2478                http2: self.http2,
2479                answers: self.answers.clone(),
2480                https_redirect_port: self.https_redirect_port,
2481                authorized_hashes: self.authorized_hashes.clone(),
2482                www_authenticate: self.www_authenticate.clone(),
2483                max_connections_per_ip: self.max_connections_per_ip,
2484                retry_after: self.retry_after,
2485                health_check: self.health_check.clone(),
2486                udp: self.udp.clone(),
2487            })
2488            .into(),
2489        ];
2490
2491        for frontend in &self.frontends {
2492            let mut orders = frontend.generate_requests(&self.cluster_id);
2493            v.append(&mut orders);
2494        }
2495
2496        for (backend_count, backend) in self.backends.iter().enumerate() {
2497            let load_balancing_parameters = Some(LoadBalancingParams {
2498                weight: backend.weight.unwrap_or(100) as i32,
2499            });
2500
2501            v.push(
2502                RequestType::AddBackend(AddBackend {
2503                    cluster_id: self.cluster_id.clone(),
2504                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
2505                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
2506                    }),
2507                    address: backend.address.into(),
2508                    load_balancing_parameters,
2509                    sticky_id: backend.sticky_id.clone(),
2510                    backup: backend.backup,
2511                })
2512                .into(),
2513            );
2514        }
2515
2516        // POST: the order stream begins with exactly one AddCluster and emits
2517        // exactly one AddBackend per configured backend — a missing AddCluster
2518        // would orphan every backend, and a miscounted backend set would
2519        // silently drop or duplicate a backend registration.
2520        debug_assert!(
2521            matches!(
2522                v.first().and_then(|r| r.request_type.as_ref()),
2523                Some(RequestType::AddCluster(_))
2524            ),
2525            "HTTP cluster orders must lead with an AddCluster"
2526        );
2527        debug_assert_eq!(
2528            v.iter()
2529                .filter(|r| matches!(r.request_type, Some(RequestType::AddBackend(_))))
2530                .count(),
2531            self.backends.len(),
2532            "one AddBackend order per configured backend"
2533        );
2534        Ok(v)
2535    }
2536}
2537
2538#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2539pub struct TcpFrontendConfig {
2540    pub address: SocketAddr,
2541    pub tags: Option<BTreeMap<String, String>>,
2542    /// `true` when this frontend's address resolves to a `protocol = "udp"`
2543    /// listener. Resolved at config-load in [`ConfigBuilder::populate_clusters`]
2544    /// from `known_addresses`; selects `AddUdpFrontend` over `AddTcpFrontend`
2545    /// in [`TcpClusterConfig::generate_requests`]. A UDP cluster is declared as
2546    /// a `protocol = "tcp"` cluster whose frontends point at UDP listeners and
2547    /// whose datagram knobs live under `[clusters.<id>.udp]`.
2548    #[serde(default)]
2549    pub udp: bool,
2550}
2551
2552#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2553pub struct TcpClusterConfig {
2554    pub cluster_id: String,
2555    pub frontends: Vec<TcpFrontendConfig>,
2556    pub backends: Vec<BackendConfig>,
2557    #[serde(default)]
2558    pub proxy_protocol: Option<ProxyProtocolConfig>,
2559    pub load_balancing: LoadBalancingAlgorithms,
2560    pub load_metric: Option<LoadMetric>,
2561    /// Per-status template body map (already loaded from disk). Even
2562    /// though TCP clusters do not emit HTTP responses, the field is
2563    /// carried for shape uniformity with [`HttpClusterConfig`].
2564    #[serde(default)]
2565    pub answers: BTreeMap<String, String>,
2566    #[serde(default)]
2567    pub https_redirect_port: Option<u32>,
2568    #[serde(default)]
2569    pub authorized_hashes: Vec<String>,
2570    #[serde(default)]
2571    pub www_authenticate: Option<String>,
2572    /// Per-cluster override of the global `max_connections_per_ip`. See
2573    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
2574    #[serde(default)]
2575    pub max_connections_per_ip: Option<u64>,
2576    /// Per-cluster override of the global `retry_after`. TCP listeners
2577    /// never emit `Retry-After`; the field is carried for shape
2578    /// uniformity with [`HttpClusterConfig`].
2579    #[serde(default)]
2580    pub retry_after: Option<u32>,
2581    /// Optional HTTP health-check configuration. TCP clusters carry this
2582    /// field for shape uniformity with [`HttpClusterConfig`]; probes are
2583    /// HTTP/1.1 only and TCP-only backends should leave this absent.
2584    #[serde(default)]
2585    pub health_check: Option<HealthCheckConfig>,
2586    /// Optional UDP-specific cluster configuration, parsed from a
2587    /// `[clusters.<id>.udp]` block on this cluster.
2588    #[serde(default)]
2589    pub udp: Option<UdpClusterConfig>,
2590}
2591
2592impl TcpClusterConfig {
2593    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
2594        let mut v: Vec<Request> = vec![
2595            RequestType::AddCluster(Cluster {
2596                cluster_id: self.cluster_id.clone(),
2597                sticky_session: false,
2598                https_redirect: false,
2599                proxy_protocol: self.proxy_protocol.map(|s| s as i32),
2600                load_balancing: self.load_balancing as i32,
2601                load_metric: self.load_metric.map(|s| s as i32),
2602                answer_503: None,
2603                http2: None,
2604                answers: self.answers.clone(),
2605                https_redirect_port: self.https_redirect_port,
2606                authorized_hashes: self.authorized_hashes.clone(),
2607                www_authenticate: self.www_authenticate.clone(),
2608                max_connections_per_ip: self.max_connections_per_ip,
2609                retry_after: self.retry_after,
2610                health_check: self.health_check.clone(),
2611                udp: self.udp.clone(),
2612            })
2613            .into(),
2614        ];
2615
2616        for frontend in &self.frontends {
2617            // A frontend whose address resolves to a `protocol = "udp"`
2618            // listener (flagged in `populate_clusters`) is added as a UDP
2619            // frontend; all others stay TCP. Mixed TCP/UDP frontends on the
2620            // same cluster are supported.
2621            if frontend.udp {
2622                v.push(
2623                    RequestType::AddUdpFrontend(RequestUdpFrontend {
2624                        cluster_id: self.cluster_id.clone(),
2625                        address: frontend.address.into(),
2626                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
2627                    })
2628                    .into(),
2629                );
2630            } else {
2631                v.push(
2632                    RequestType::AddTcpFrontend(RequestTcpFrontend {
2633                        cluster_id: self.cluster_id.clone(),
2634                        address: frontend.address.into(),
2635                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
2636                    })
2637                    .into(),
2638                );
2639            }
2640        }
2641
2642        for (backend_count, backend) in self.backends.iter().enumerate() {
2643            let load_balancing_parameters = Some(LoadBalancingParams {
2644                weight: backend.weight.unwrap_or(100) as i32,
2645            });
2646
2647            v.push(
2648                RequestType::AddBackend(AddBackend {
2649                    cluster_id: self.cluster_id.clone(),
2650                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
2651                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
2652                    }),
2653                    address: backend.address.into(),
2654                    load_balancing_parameters,
2655                    sticky_id: backend.sticky_id.clone(),
2656                    backup: backend.backup,
2657                })
2658                .into(),
2659            );
2660        }
2661
2662        // POST: the order stream leads with one AddCluster and emits exactly
2663        // one AddTcpFrontend per frontend and one AddBackend per backend — the
2664        // worker reconstructs the cluster topology solely from these counts.
2665        debug_assert!(
2666            matches!(
2667                v.first().and_then(|r| r.request_type.as_ref()),
2668                Some(RequestType::AddCluster(_))
2669            ),
2670            "TCP cluster orders must lead with an AddCluster"
2671        );
2672        debug_assert_eq!(
2673            v.iter()
2674                .filter(|r| matches!(
2675                    r.request_type,
2676                    Some(RequestType::AddTcpFrontend(_)) | Some(RequestType::AddUdpFrontend(_))
2677                ))
2678                .count(),
2679            self.frontends.len(),
2680            "one AddTcpFrontend or AddUdpFrontend order per configured frontend"
2681        );
2682        debug_assert_eq!(
2683            v.iter()
2684                .filter(|r| matches!(r.request_type, Some(RequestType::AddBackend(_))))
2685                .count(),
2686            self.backends.len(),
2687            "one AddBackend order per configured backend"
2688        );
2689        Ok(v)
2690    }
2691}
2692
2693#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2694pub enum ClusterConfig {
2695    Http(HttpClusterConfig),
2696    Tcp(TcpClusterConfig),
2697}
2698
2699impl ClusterConfig {
2700    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
2701        match *self {
2702            ClusterConfig::Http(ref http) => http.generate_requests(),
2703            ClusterConfig::Tcp(ref tcp) => tcp.generate_requests(),
2704        }
2705    }
2706}
2707
2708/// Parsed from the TOML config provided by the user.
2709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
2710pub struct FileConfig {
2711    pub command_socket: Option<String>,
2712    pub command_buffer_size: Option<u64>,
2713    pub max_command_buffer_size: Option<u64>,
2714    pub max_connections: Option<usize>,
2715    pub min_buffers: Option<u64>,
2716    pub max_buffers: Option<u64>,
2717    pub buffer_size: Option<u64>,
2718    /// Slab-entries-per-connection multiplier. `None` keeps the compile-time
2719    /// default of 4. Operator-visible escape hatch for fan-out topologies
2720    /// that exceed 4 backends per session — clamped to [2, 32] at load.
2721    #[serde(default)]
2722    pub slab_entries_per_connection: Option<u64>,
2723    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
2724    /// payload accepted by the worker's `mux::auth` module. Caps the
2725    /// per-failed-auth allocation so a hostile peer cannot force the worker
2726    /// to decode arbitrarily large tokens. RFC 7617 imposes no upper bound
2727    /// — defaults to 4096, which is well above the realistic
2728    /// `username:password` shape. Operators running hardened tenants can
2729    /// lower this to e.g. 256 or 512 to bound the allocation tighter.
2730    /// Values >= `buffer_size / 3` emit a warning at config-load time
2731    /// (the credential cap shouldn't dominate the per-frontend buffer).
2732    #[serde(default)]
2733    pub basic_auth_max_credential_bytes: Option<u64>,
2734    /// Default per-(cluster, source-IP) connection limit. `None` keeps
2735    /// `0` (unlimited). Each cluster may override via its own
2736    /// `max_connections_per_ip`. The source IP is taken from the parsed
2737    /// proxy-protocol header when present, else `peer_addr`. When the
2738    /// limit is reached, HTTP requests are answered with `429 Too Many
2739    /// Requests` (with optional `Retry-After`) and TCP sessions are
2740    /// closed gracefully without dialing the backend.
2741    #[serde(default)]
2742    pub max_connections_per_ip: Option<u64>,
2743    /// Default `Retry-After` header value (seconds) sent on HTTP 429
2744    /// responses. `Some(0)` or `None` keeping the default `0` omits the
2745    /// header (rendering `Retry-After: 0` invites an immediate retry that
2746    /// defeats the limit). Per-cluster overrides apply for HTTP listeners
2747    /// only. TCP listeners ignore this value (no HTTP envelope).
2748    #[serde(default)]
2749    pub retry_after: Option<u32>,
2750    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
2751    /// zero-copy direction (Linux only, `splice` feature). `None` keeps
2752    /// the kernel default (64 KiB). Applied via `fcntl(F_SETPIPE_SZ)`;
2753    /// the kernel rounds up to a page boundary and clamps at
2754    /// `/proc/sys/fs/pipe-max-size` (default 1 MiB unprivileged). The
2755    /// realised capacity is read back via `fcntl(F_GETPIPE_SZ)` and
2756    /// drives the per-call `len` for `splice_in`. Ignored on non-Linux
2757    /// targets and on builds without the `splice` feature.
2758    #[serde(default)]
2759    pub splice_pipe_capacity_bytes: Option<u64>,
2760    /// Optional UID allowlist for command-socket requests. `None` (default)
2761    /// preserves historical behaviour: any same-UID local process can
2762    /// invoke any verb. When set, requests whose `SO_PEERCRED` UID is not
2763    /// in the list are rejected. Use to restrict mutating verbs to a
2764    /// specific operator UID even when other same-UID daemons coexist
2765    /// (CI runners, monitoring).
2766    #[serde(default)]
2767    pub command_allowed_uids: Option<Vec<u32>>,
2768    pub saved_state: Option<String>,
2769    #[serde(default)]
2770    pub automatic_state_save: Option<bool>,
2771    pub log_level: Option<String>,
2772    pub log_target: Option<String>,
2773    #[serde(default)]
2774    pub log_colored: bool,
2775    /// Dedicated file path for the control-plane audit log. When set, every
2776    /// emitted `[AUDIT]` / `Command(...)` line is also appended to this file
2777    /// opened `O_APPEND | O_CREAT` with mode `0o640` (owner read+write,
2778    /// group read, world nothing) so operators can separate the audit trail
2779    /// from the main log stream and protect it with group-scoped ACLs /
2780    /// logrotate. Independent of the standard `log_target`. `None` keeps
2781    /// audit lines routed only through the standard logger.
2782    #[serde(default)]
2783    pub audit_logs_target: Option<String>,
2784    /// Dedicated file path for a JSON-encoded mirror of the audit log.
2785    /// One JSON object per line so SIEM pipelines (Wazuh, Elastic, Loki)
2786    /// ingest without bespoke parsers. Same `O_APPEND | O_CREAT | 0o640`
2787    /// as `audit_logs_target`. `None` disables the JSON mirror.
2788    #[serde(default)]
2789    pub audit_logs_json_target: Option<String>,
2790    #[serde(default)]
2791    pub access_logs_target: Option<String>,
2792    #[serde(default)]
2793    pub access_logs_format: Option<AccessLogFormat>,
2794    #[serde(default)]
2795    pub access_logs_colored: Option<bool>,
2796    pub worker_count: Option<u16>,
2797    pub worker_automatic_restart: Option<bool>,
2798    pub metrics: Option<MetricsConfig>,
2799    pub disable_cluster_metrics: Option<bool>,
2800    pub listeners: Option<Vec<ListenerBuilder>>,
2801    pub clusters: Option<HashMap<String, FileClusterConfig>>,
2802    pub handle_process_affinity: Option<bool>,
2803    pub ctl_command_timeout: Option<u64>,
2804    pub pid_file_path: Option<String>,
2805    pub activate_listeners: Option<bool>,
2806    #[serde(default)]
2807    pub front_timeout: Option<u32>,
2808    #[serde(default)]
2809    pub back_timeout: Option<u32>,
2810    #[serde(default)]
2811    pub connect_timeout: Option<u32>,
2812    #[serde(default)]
2813    pub zombie_check_interval: Option<u32>,
2814    #[serde(default)]
2815    pub accept_queue_timeout: Option<u32>,
2816    #[serde(default)]
2817    pub evict_on_queue_full: Option<bool>,
2818    #[serde(default)]
2819    pub request_timeout: Option<u32>,
2820    #[serde(default)]
2821    pub worker_timeout: Option<u32>,
2822}
2823
2824impl FileConfig {
2825    pub fn load_from_path(path: &str) -> Result<FileConfig, ConfigError> {
2826        let data = Config::load_file(path)?;
2827
2828        let config: FileConfig = match toml::from_str(&data) {
2829            Ok(config) => config,
2830            Err(e) => {
2831                display_toml_error(&data, &e);
2832                return Err(ConfigError::DeserializeToml(e.to_string()));
2833            }
2834        };
2835
2836        let mut reserved_address: HashSet<SocketAddr> = HashSet::new();
2837
2838        if let Some(listeners) = config.listeners.as_ref() {
2839            for listener in listeners.iter() {
2840                if reserved_address.contains(&listener.address) {
2841                    return Err(ConfigError::ListenerAddressAlreadyInUse(listener.address));
2842                }
2843                reserved_address.insert(listener.address);
2844            }
2845        }
2846
2847        //FIXME: verify how clusters and listeners share addresses
2848        /*
2849        if let Some(ref clusters) = config.clusters {
2850          for (key, cluster) in clusters.iter() {
2851            if let (Some(address), Some(port)) = (cluster.ip_address.clone(), cluster.port) {
2852              let addr = (address, port);
2853              if reserved_address.contains(&addr) {
2854                println!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
2855                  key, addr.0, addr.1);
2856                return Err(Error::new(
2857                  ErrorKind::InvalidData,
2858                  format!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
2859                    key, addr.0, addr.1)));
2860              } else {
2861                reserved_address.insert(addr.clone());
2862              }
2863            }
2864          }
2865        }
2866        */
2867
2868        Ok(config)
2869    }
2870}
2871
2872/// A builder that converts [FileConfig] to [Config]
2873pub struct ConfigBuilder {
2874    file: FileConfig,
2875    known_addresses: HashMap<SocketAddr, ListenerProtocol>,
2876    expect_proxy_addresses: HashSet<SocketAddr>,
2877    built: Config,
2878}
2879
2880impl ConfigBuilder {
2881    /// starts building a [Config] with values from a [FileConfig], or defaults.
2882    ///
2883    /// please provide a config path, usefull for rebuilding the config later.
2884    pub fn new<S>(file_config: FileConfig, config_path: S) -> Self
2885    where
2886        S: ToString,
2887    {
2888        let built = Config {
2889            accept_queue_timeout: file_config
2890                .accept_queue_timeout
2891                .unwrap_or(DEFAULT_ACCEPT_QUEUE_TIMEOUT),
2892            evict_on_queue_full: file_config
2893                .evict_on_queue_full
2894                .unwrap_or(DEFAULT_EVICT_ON_QUEUE_FULL),
2895            activate_listeners: file_config.activate_listeners.unwrap_or(true),
2896            automatic_state_save: file_config
2897                .automatic_state_save
2898                .unwrap_or(DEFAULT_AUTOMATIC_STATE_SAVE),
2899            back_timeout: file_config.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
2900            buffer_size: file_config.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE),
2901            command_buffer_size: file_config
2902                .command_buffer_size
2903                .unwrap_or(DEFAULT_COMMAND_BUFFER_SIZE),
2904            config_path: config_path.to_string(),
2905            connect_timeout: file_config
2906                .connect_timeout
2907                .unwrap_or(DEFAULT_CONNECT_TIMEOUT),
2908            ctl_command_timeout: file_config.ctl_command_timeout.unwrap_or(1_000),
2909            front_timeout: file_config.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
2910            handle_process_affinity: file_config.handle_process_affinity.unwrap_or(false),
2911            access_logs_target: file_config.access_logs_target.clone(),
2912            audit_logs_target: file_config.audit_logs_target.clone(),
2913            audit_logs_json_target: file_config.audit_logs_json_target.clone(),
2914            access_logs_format: file_config.access_logs_format.clone(),
2915            access_logs_colored: file_config.access_logs_colored,
2916            log_level: file_config
2917                .log_level
2918                .clone()
2919                .unwrap_or_else(|| String::from("info")),
2920            log_target: file_config
2921                .log_target
2922                .clone()
2923                .unwrap_or_else(|| String::from("stdout")),
2924            log_colored: file_config.log_colored,
2925            max_buffers: file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
2926            max_command_buffer_size: file_config
2927                .max_command_buffer_size
2928                .unwrap_or(DEFAULT_MAX_COMMAND_BUFFER_SIZE),
2929            max_connections: file_config
2930                .max_connections
2931                .unwrap_or(DEFAULT_MAX_CONNECTIONS),
2932            metrics: file_config.metrics.clone(),
2933            disable_cluster_metrics: file_config
2934                .disable_cluster_metrics
2935                .unwrap_or(DEFAULT_DISABLE_CLUSTER_METRICS),
2936            min_buffers: std::cmp::min(
2937                file_config.min_buffers.unwrap_or(DEFAULT_MIN_BUFFERS),
2938                file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
2939            ),
2940            pid_file_path: file_config.pid_file_path.clone(),
2941            request_timeout: file_config
2942                .request_timeout
2943                .unwrap_or(DEFAULT_REQUEST_TIMEOUT),
2944            saved_state: file_config.saved_state.clone(),
2945            worker_automatic_restart: file_config
2946                .worker_automatic_restart
2947                .unwrap_or(DEFAULT_WORKER_AUTOMATIC_RESTART),
2948            worker_count: file_config.worker_count.unwrap_or(DEFAULT_WORKER_COUNT),
2949            zombie_check_interval: file_config
2950                .zombie_check_interval
2951                .unwrap_or(DEFAULT_ZOMBIE_CHECK_INTERVAL),
2952            worker_timeout: file_config.worker_timeout.unwrap_or(DEFAULT_WORKER_TIMEOUT),
2953            slab_entries_per_connection: file_config.slab_entries_per_connection.map(|n| {
2954                n.clamp(
2955                    ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION,
2956                    ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION,
2957                )
2958            }),
2959            command_allowed_uids: file_config.command_allowed_uids.clone(),
2960            basic_auth_max_credential_bytes: file_config.basic_auth_max_credential_bytes,
2961            max_connections_per_ip: file_config
2962                .max_connections_per_ip
2963                .unwrap_or(DEFAULT_MAX_CONNECTIONS_PER_IP),
2964            retry_after: file_config.retry_after.unwrap_or(DEFAULT_RETRY_AFTER),
2965            splice_pipe_capacity_bytes: file_config.splice_pipe_capacity_bytes,
2966            ..Default::default()
2967        };
2968
2969        // POST: the buffer free-list floor is clamped to never exceed the
2970        // ceiling — the `std::cmp::min(min_buffers, max_buffers)` above is the
2971        // sole guarantor of this, so assert it held.
2972        debug_assert!(
2973            built.min_buffers <= built.max_buffers,
2974            "min_buffers must be clamped to <= max_buffers in the builder"
2975        );
2976        // POST: an explicit slab override, if present, was clamped into the
2977        // documented [MIN, MAX] window; an absent override stays None.
2978        debug_assert!(
2979            built.slab_entries_per_connection.is_none_or(|n| {
2980                (ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION
2981                    ..=ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION)
2982                    .contains(&n)
2983            }),
2984            "a set slab_entries_per_connection must be clamped into [MIN, MAX]"
2985        );
2986
2987        Self {
2988            file: file_config,
2989            known_addresses: HashMap::new(),
2990            expect_proxy_addresses: HashSet::new(),
2991            built,
2992        }
2993    }
2994
2995    fn push_tls_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
2996        let listener = listener.to_tls(Some(&self.built))?;
2997        self.built.https_listeners.push(listener);
2998        Ok(())
2999    }
3000
3001    fn push_http_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
3002        let listener = listener.to_http(Some(&self.built))?;
3003        self.built.http_listeners.push(listener);
3004        Ok(())
3005    }
3006
3007    fn push_tcp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
3008        let listener = listener.to_tcp(Some(&self.built))?;
3009        self.built.tcp_listeners.push(listener);
3010        Ok(())
3011    }
3012
3013    fn push_udp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
3014        let listener = listener.to_udp(Some(&self.built))?;
3015        self.built.udp_listeners.push(listener);
3016        Ok(())
3017    }
3018
3019    fn populate_listeners(&mut self, listeners: Vec<ListenerBuilder>) -> Result<(), ConfigError> {
3020        for listener in listeners.iter() {
3021            if self.known_addresses.contains_key(&listener.address) {
3022                return Err(ConfigError::ListenerAddressAlreadyInUse(listener.address));
3023            }
3024
3025            let protocol = listener
3026                .protocol
3027                .ok_or(ConfigError::Missing(MissingKind::Protocol))?;
3028
3029            self.known_addresses.insert(listener.address, protocol);
3030            if listener.expect_proxy == Some(true) {
3031                self.expect_proxy_addresses.insert(listener.address);
3032            }
3033
3034            if listener.public_address.is_some() && listener.expect_proxy == Some(true) {
3035                return Err(ConfigError::Incompatible {
3036                    object: ObjectKind::Listener,
3037                    id: listener.address.to_string(),
3038                    kind: IncompatibilityKind::PublicAddress,
3039                });
3040            }
3041
3042            match protocol {
3043                ListenerProtocol::Https => self.push_tls_listener(listener.clone())?,
3044                ListenerProtocol::Http => self.push_http_listener(listener.clone())?,
3045                ListenerProtocol::Tcp => self.push_tcp_listener(listener.clone())?,
3046                ListenerProtocol::Udp => self.push_udp_listener(listener.clone())?,
3047            }
3048        }
3049        Ok(())
3050    }
3051
3052    fn populate_clusters(
3053        &mut self,
3054        mut file_cluster_configs: HashMap<String, FileClusterConfig>,
3055    ) -> Result<(), ConfigError> {
3056        for (id, file_cluster_config) in file_cluster_configs.drain() {
3057            let mut cluster_config =
3058                file_cluster_config.to_cluster_config(id.as_str(), &self.expect_proxy_addresses)?;
3059
3060            match cluster_config {
3061                ClusterConfig::Http(ref mut http) => {
3062                    for frontend in http.frontends.iter_mut() {
3063                        match self.known_addresses.get(&frontend.address) {
3064                            Some(ListenerProtocol::Tcp) => {
3065                                return Err(ConfigError::WrongFrontendProtocol(
3066                                    ListenerProtocol::Tcp,
3067                                ));
3068                            }
3069                            Some(ListenerProtocol::Udp) => {
3070                                return Err(ConfigError::WrongFrontendProtocol(
3071                                    ListenerProtocol::Udp,
3072                                ));
3073                            }
3074                            Some(ListenerProtocol::Http) => {
3075                                if frontend.certificate.is_some() {
3076                                    return Err(ConfigError::WrongFrontendProtocol(
3077                                        ListenerProtocol::Http,
3078                                    ));
3079                                }
3080                            }
3081                            Some(ListenerProtocol::Https) => {
3082                                if frontend.certificate.is_none() {
3083                                    if let Some(https_listener) =
3084                                        self.built.https_listeners.iter().find(|listener| {
3085                                            listener.address == frontend.address.into()
3086                                                && listener.certificate.is_some()
3087                                        })
3088                                    {
3089                                        //println!("using listener certificate for {:}", frontend.address);
3090                                        frontend
3091                                            .certificate
3092                                            .clone_from(&https_listener.certificate);
3093                                        frontend.certificate_chain =
3094                                            Some(https_listener.certificate_chain.clone());
3095                                        frontend.key.clone_from(&https_listener.key);
3096                                    }
3097                                    if frontend.certificate.is_none() {
3098                                        debug!("known addresses: {:?}", self.known_addresses);
3099                                        debug!("frontend: {:?}", frontend);
3100                                        return Err(ConfigError::WrongFrontendProtocol(
3101                                            ListenerProtocol::Https,
3102                                        ));
3103                                    }
3104                                }
3105                            }
3106                            None => {
3107                                // create a default listener for that front
3108                                let file_listener_protocol = if frontend.certificate.is_some() {
3109                                    self.push_tls_listener(ListenerBuilder::new(
3110                                        frontend.address.into(),
3111                                        ListenerProtocol::Https,
3112                                    ))?;
3113
3114                                    ListenerProtocol::Https
3115                                } else {
3116                                    self.push_http_listener(ListenerBuilder::new(
3117                                        frontend.address.into(),
3118                                        ListenerProtocol::Http,
3119                                    ))?;
3120
3121                                    ListenerProtocol::Http
3122                                };
3123                                self.known_addresses
3124                                    .insert(frontend.address, file_listener_protocol);
3125                            }
3126                        }
3127                    }
3128                }
3129                ClusterConfig::Tcp(ref mut tcp) => {
3130                    //FIXME: verify that different TCP clusters do not request the same address
3131                    for frontend in tcp.frontends.iter_mut() {
3132                        match self.known_addresses.get(&frontend.address) {
3133                            Some(ListenerProtocol::Http) | Some(ListenerProtocol::Https) => {
3134                                return Err(ConfigError::WrongFrontendProtocol(
3135                                    ListenerProtocol::Http,
3136                                ));
3137                            }
3138                            Some(ListenerProtocol::Udp) => {
3139                                // A `protocol = "tcp"` cluster whose frontend
3140                                // points at a `protocol = "udp"` listener is a
3141                                // UDP cluster (datagram knobs under
3142                                // `[clusters.<id>.udp]`). Mark the frontend so
3143                                // `generate_requests` emits `AddUdpFrontend`.
3144                                frontend.udp = true;
3145                            }
3146                            Some(ListenerProtocol::Tcp) => {}
3147                            None => {
3148                                // create a default listener for that front
3149                                self.push_tcp_listener(ListenerBuilder::new(
3150                                    frontend.address.into(),
3151                                    ListenerProtocol::Tcp,
3152                                ))?;
3153                                self.known_addresses
3154                                    .insert(frontend.address, ListenerProtocol::Tcp);
3155                            }
3156                        }
3157                    }
3158                }
3159            }
3160
3161            self.built.clusters.insert(id, cluster_config);
3162        }
3163        Ok(())
3164    }
3165
3166    /// Builds a [`Config`], populated with listeners and clusters
3167    pub fn into_config(&mut self) -> Result<Config, ConfigError> {
3168        if let Some(listeners) = &self.file.listeners {
3169            self.populate_listeners(listeners.clone())?;
3170        }
3171
3172        if let Some(file_cluster_configs) = &self.file.clusters {
3173            self.populate_clusters(file_cluster_configs.clone())?;
3174        }
3175
3176        // RFC 9113 §6.5.2 + §4.1: the H2 mux must accept up to
3177        // SETTINGS_MAX_FRAME_SIZE (16 384) + 9-byte frame header in a single
3178        // kawa buffer. If any HTTPS listener advertises "h2" in its ALPN list
3179        // and the global buffer_size is below H2_MIN_BUFFER_SIZE, the mux
3180        // deadlocks on full-size DATA / HEADERS / CONTINUATION frames until
3181        // the session timeout fires. Reject at config load so the failure
3182        // mode surfaces at boot, not under traffic.
3183        // Long-form rationale: `lib/src/protocol/mux/LIFECYCLE.md`.
3184        let h2_listeners = self
3185            .built
3186            .https_listeners
3187            .iter()
3188            .filter(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
3189            .count();
3190        if h2_listeners > 0 && self.built.buffer_size < H2_MIN_BUFFER_SIZE {
3191            return Err(ConfigError::BufferSizeTooSmallForH2 {
3192                buffer_size: self.built.buffer_size,
3193                minimum: H2_MIN_BUFFER_SIZE,
3194                listeners: h2_listeners,
3195            });
3196        }
3197
3198        // Warn (no hard reject) when the configured Basic-auth credential
3199        // cap is large enough to dominate the per-frontend buffer. The
3200        // worker copies a decoded credential into a transient allocation
3201        // sized by this cap; values >= 33% of `buffer_size` mean a single
3202        // failed-auth attempt can hold a third of the buffer's worth of
3203        // bytes, which combined with in-flight request/response framing
3204        // pushes the buffer toward back-pressure under load. Log only —
3205        // operators with deliberate threat models may choose this
3206        // trade-off, but the surprise needs to be visible.
3207        if let Some(cap) = self.built.basic_auth_max_credential_bytes {
3208            let third = self.built.buffer_size / 3;
3209            if cap >= third {
3210                warn!(
3211                    "basic_auth_max_credential_bytes = {} is >= buffer_size / 3 ({}); \
3212                     a hostile peer can pin ~33% of the per-frontend buffer per failed auth \
3213                     attempt. Consider lowering basic_auth_max_credential_bytes (typical \
3214                     credentials are <100 bytes) or raising buffer_size.",
3215                    cap, third
3216                );
3217            }
3218        }
3219
3220        // The eviction batch is `(max_connections / 100).max(1)` — a 1% ratio
3221        // by design. Below 100 connections the floor of 1 means each cap
3222        // event evicts a larger share than 1% of capacity (e.g. 4% at
3223        // max_connections=25), which can surprise an operator who reads the
3224        // knob as "1% per round". Warn at config load so the discrepancy is
3225        // visible at boot, not under traffic.
3226        if self.built.evict_on_queue_full && self.built.max_connections < 100 {
3227            let pct = 100usize.div_ceil(self.built.max_connections);
3228            warn!(
3229                "evict_on_queue_full enabled with max_connections = {}; the eviction batch \
3230                 clamps to 1, equivalent to ~{}% of capacity per cap event (the knob is \
3231                 documented as 1%). Confirm this is intended.",
3232                self.built.max_connections, pct
3233            );
3234        }
3235
3236        let command_socket_path = self.file.command_socket.clone().unwrap_or({
3237            let mut path = env::current_dir().map_err(|e| ConfigError::Env(e.to_string()))?;
3238            path.push("sozu.sock");
3239            let verified_path = path
3240                .to_str()
3241                .ok_or(ConfigError::InvalidPath(path.clone()))?;
3242            verified_path.to_owned()
3243        });
3244
3245        if let (None, Some(true)) = (&self.file.saved_state, &self.file.automatic_state_save) {
3246            return Err(ConfigError::Missing(MissingKind::SavedState));
3247        }
3248
3249        let config = Config {
3250            command_socket: command_socket_path,
3251            ..self.built.clone()
3252        };
3253
3254        // POST: a successfully built config satisfies the buffer-pool
3255        // invariants every worker relies on.
3256        // 1. min_buffers <= max_buffers — guaranteed by the `std::cmp::min`
3257        //    clamp in `new`; a violation would let a worker size its free-list
3258        //    floor above its ceiling.
3259        debug_assert!(
3260            config.min_buffers <= config.max_buffers,
3261            "min_buffers must not exceed max_buffers"
3262        );
3263        // 2. If any HTTPS listener advertises h2 in its ALPN, the global
3264        //    buffer_size is at least the H2 minimum — otherwise the early
3265        //    return above would have produced a BufferSizeTooSmallForH2 error
3266        //    rather than this Ok. (Recomputed here so the assert is independent
3267        //    of the local `h2_listeners` binding above.)
3268        debug_assert!(
3269            !config
3270                .https_listeners
3271                .iter()
3272                .any(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
3273                || config.buffer_size >= H2_MIN_BUFFER_SIZE,
3274            "an h2-advertising config must satisfy the H2 minimum buffer size"
3275        );
3276        Ok(config)
3277    }
3278}
3279
3280/// Sōzu configuration, populated with clusters and listeners.
3281///
3282/// This struct is used on startup to generate `WorkerRequest`s
3283#[derive(Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
3284pub struct Config {
3285    pub config_path: String,
3286    pub command_socket: String,
3287    pub command_buffer_size: u64,
3288    pub max_command_buffer_size: u64,
3289    pub max_connections: usize,
3290    pub min_buffers: u64,
3291    pub max_buffers: u64,
3292    pub buffer_size: u64,
3293    pub saved_state: Option<String>,
3294    #[serde(default)]
3295    pub automatic_state_save: bool,
3296    pub log_level: String,
3297    pub log_target: String,
3298    pub log_colored: bool,
3299    /// Optional dedicated file path for the control-plane audit log. See
3300    /// `FileConfig::audit_logs_target` for rationale.
3301    #[serde(default)]
3302    pub audit_logs_target: Option<String>,
3303    /// Optional JSON mirror of the audit log; see
3304    /// `FileConfig::audit_logs_json_target`.
3305    #[serde(default)]
3306    pub audit_logs_json_target: Option<String>,
3307    #[serde(default)]
3308    pub access_logs_target: Option<String>,
3309    pub access_logs_format: Option<AccessLogFormat>,
3310    pub access_logs_colored: Option<bool>,
3311    pub worker_count: u16,
3312    pub worker_automatic_restart: bool,
3313    pub metrics: Option<MetricsConfig>,
3314    #[serde(default = "default_disable_cluster_metrics")]
3315    pub disable_cluster_metrics: bool,
3316    pub http_listeners: Vec<HttpListenerConfig>,
3317    pub https_listeners: Vec<HttpsListenerConfig>,
3318    pub tcp_listeners: Vec<TcpListenerConfig>,
3319    #[serde(default)]
3320    pub udp_listeners: Vec<UdpListenerConfig>,
3321    pub clusters: HashMap<String, ClusterConfig>,
3322    pub handle_process_affinity: bool,
3323    pub ctl_command_timeout: u64,
3324    pub pid_file_path: Option<String>,
3325    pub activate_listeners: bool,
3326    #[serde(default = "default_front_timeout")]
3327    pub front_timeout: u32,
3328    #[serde(default = "default_back_timeout")]
3329    pub back_timeout: u32,
3330    #[serde(default = "default_connect_timeout")]
3331    pub connect_timeout: u32,
3332    #[serde(default = "default_zombie_check_interval")]
3333    pub zombie_check_interval: u32,
3334    #[serde(default = "default_accept_queue_timeout")]
3335    pub accept_queue_timeout: u32,
3336    #[serde(default = "default_evict_on_queue_full")]
3337    pub evict_on_queue_full: bool,
3338    #[serde(default = "default_request_timeout")]
3339    pub request_timeout: u32,
3340    #[serde(default = "default_worker_timeout")]
3341    pub worker_timeout: u32,
3342    /// Slab-entries-per-connection multiplier exposed for operators with
3343    /// fan-out topologies that exceed the default 4 backends per session.
3344    /// `None` means the default (4) applies; set values are clamped to
3345    /// [`ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION`,
3346    /// `ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION`] = [2, 32]. Slab
3347    /// capacity is `10 + slab_entries_per_connection * max_connections`.
3348    #[serde(default)]
3349    pub slab_entries_per_connection: Option<u64>,
3350    /// Optional allowlist of UIDs permitted to invoke command-socket
3351    /// requests. `None` keeps the historical "any same-UID local process"
3352    /// behaviour. When `Some`, every request whose `SO_PEERCRED` UID is
3353    /// not in the list is rejected before reaching dispatch.
3354    #[serde(default)]
3355    pub command_allowed_uids: Option<Vec<u32>>,
3356    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
3357    /// payload accepted by `mux::auth`. `None` keeps the compile-time
3358    /// default of 4096. Set once on each worker at boot via
3359    /// [`ServerConfig::basic_auth_max_credential_bytes`].
3360    #[serde(default)]
3361    pub basic_auth_max_credential_bytes: Option<u64>,
3362    /// Default per-(cluster, source-IP) connection limit. `0` means
3363    /// unlimited. Each cluster may override via its own
3364    /// `max_connections_per_ip`. Source IP attribution honours the
3365    /// proxy-protocol header when present.
3366    #[serde(default = "default_max_connections_per_ip")]
3367    pub max_connections_per_ip: u64,
3368    /// Default `Retry-After` header value (seconds) emitted on HTTP 429
3369    /// responses. `0` omits the header.
3370    #[serde(default = "default_retry_after")]
3371    pub retry_after: u32,
3372    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
3373    /// zero-copy direction. `None` keeps the kernel default of 64 KiB.
3374    /// Applied via `fcntl(F_SETPIPE_SZ)` per pipe at `SplicePipe::new`;
3375    /// the kernel rounds up to a page boundary and clamps at
3376    /// `/proc/sys/fs/pipe-max-size`. Linux-only; ignored on builds
3377    /// without the `splice` feature.
3378    #[serde(default)]
3379    pub splice_pipe_capacity_bytes: Option<u64>,
3380}
3381
3382fn default_front_timeout() -> u32 {
3383    DEFAULT_FRONT_TIMEOUT
3384}
3385
3386fn default_back_timeout() -> u32 {
3387    DEFAULT_BACK_TIMEOUT
3388}
3389
3390fn default_connect_timeout() -> u32 {
3391    DEFAULT_CONNECT_TIMEOUT
3392}
3393
3394fn default_request_timeout() -> u32 {
3395    DEFAULT_REQUEST_TIMEOUT
3396}
3397
3398fn default_zombie_check_interval() -> u32 {
3399    DEFAULT_ZOMBIE_CHECK_INTERVAL
3400}
3401
3402fn default_accept_queue_timeout() -> u32 {
3403    DEFAULT_ACCEPT_QUEUE_TIMEOUT
3404}
3405
3406fn default_evict_on_queue_full() -> bool {
3407    DEFAULT_EVICT_ON_QUEUE_FULL
3408}
3409
3410fn default_disable_cluster_metrics() -> bool {
3411    DEFAULT_DISABLE_CLUSTER_METRICS
3412}
3413
3414fn default_worker_timeout() -> u32 {
3415    DEFAULT_WORKER_TIMEOUT
3416}
3417
3418fn default_max_connections_per_ip() -> u64 {
3419    DEFAULT_MAX_CONNECTIONS_PER_IP
3420}
3421
3422fn default_retry_after() -> u32 {
3423    DEFAULT_RETRY_AFTER
3424}
3425
3426impl Config {
3427    /// Parse a TOML file and build a config out of it
3428    pub fn load_from_path(path: &str) -> Result<Config, ConfigError> {
3429        let file_config = FileConfig::load_from_path(path)?;
3430
3431        let mut config = ConfigBuilder::new(file_config, path).into_config()?;
3432
3433        // replace saved_state with a verified path
3434        config.saved_state = config.saved_state_path()?;
3435
3436        Ok(config)
3437    }
3438
3439    /// yields requests intended to recreate a proxy that match the config
3440    pub fn generate_config_messages(&self) -> Result<Vec<WorkerRequest>, ConfigError> {
3441        let mut v = Vec::new();
3442        let mut count = 0u8;
3443
3444        for listener in &self.http_listeners {
3445            v.push(WorkerRequest {
3446                id: format!("CONFIG-{count}"),
3447                content: RequestType::AddHttpListener(listener.clone()).into(),
3448            });
3449            count += 1;
3450        }
3451
3452        for listener in &self.https_listeners {
3453            v.push(WorkerRequest {
3454                id: format!("CONFIG-{count}"),
3455                content: RequestType::AddHttpsListener(listener.clone()).into(),
3456            });
3457            count += 1;
3458        }
3459
3460        for listener in &self.tcp_listeners {
3461            v.push(WorkerRequest {
3462                id: format!("CONFIG-{count}"),
3463                content: RequestType::AddTcpListener(*listener).into(),
3464            });
3465            count += 1;
3466        }
3467
3468        for listener in &self.udp_listeners {
3469            v.push(WorkerRequest {
3470                id: format!("CONFIG-{count}"),
3471                content: RequestType::AddUdpListener(*listener).into(),
3472            });
3473            count += 1;
3474        }
3475
3476        for cluster in self.clusters.values() {
3477            let mut orders = cluster.generate_requests()?;
3478            for content in orders.drain(..) {
3479                v.push(WorkerRequest {
3480                    id: format!("CONFIG-{count}"),
3481                    content,
3482                });
3483                count += 1;
3484            }
3485        }
3486
3487        if self.activate_listeners {
3488            for listener in &self.http_listeners {
3489                v.push(WorkerRequest {
3490                    id: format!("CONFIG-{count}"),
3491                    content: RequestType::ActivateListener(ActivateListener {
3492                        address: listener.address,
3493                        proxy: ListenerType::Http.into(),
3494                        from_scm: false,
3495                    })
3496                    .into(),
3497                });
3498                count += 1;
3499            }
3500
3501            for listener in &self.https_listeners {
3502                v.push(WorkerRequest {
3503                    id: format!("CONFIG-{count}"),
3504                    content: RequestType::ActivateListener(ActivateListener {
3505                        address: listener.address,
3506                        proxy: ListenerType::Https.into(),
3507                        from_scm: false,
3508                    })
3509                    .into(),
3510                });
3511                count += 1;
3512            }
3513
3514            for listener in &self.tcp_listeners {
3515                v.push(WorkerRequest {
3516                    id: format!("CONFIG-{count}"),
3517                    content: RequestType::ActivateListener(ActivateListener {
3518                        address: listener.address,
3519                        proxy: ListenerType::Tcp.into(),
3520                        from_scm: false,
3521                    })
3522                    .into(),
3523                });
3524                count += 1;
3525            }
3526
3527            for listener in &self.udp_listeners {
3528                v.push(WorkerRequest {
3529                    id: format!("CONFIG-{count}"),
3530                    content: RequestType::ActivateListener(ActivateListener {
3531                        address: listener.address,
3532                        proxy: ListenerType::Udp.into(),
3533                        from_scm: false,
3534                    })
3535                    .into(),
3536                });
3537                count += 1;
3538            }
3539        }
3540
3541        if self.disable_cluster_metrics {
3542            v.push(WorkerRequest {
3543                id: format!("CONFIG-{count}"),
3544                content: RequestType::ConfigureMetrics(MetricsConfiguration::Disabled.into())
3545                    .into(),
3546            });
3547            // count += 1; // uncomment if code is added below
3548        }
3549
3550        Ok(v)
3551    }
3552
3553    /// Get the path of the UNIX socket used to communicate with Sōzu
3554    pub fn command_socket_path(&self) -> Result<String, ConfigError> {
3555        let config_path_buf = PathBuf::from(self.config_path.clone());
3556        let mut config_dir = config_path_buf
3557            .parent()
3558            .ok_or(ConfigError::NoFileParent(
3559                config_path_buf.to_string_lossy().to_string(),
3560            ))?
3561            .to_path_buf();
3562
3563        let socket_path = PathBuf::from(self.command_socket.clone());
3564
3565        let mut socket_parent_dir = match socket_path.parent() {
3566            // if the socket path is of the form "./sozu.sock",
3567            // then the parent is the directory where config.toml is situated
3568            None => config_dir,
3569            Some(path) => {
3570                // concatenate the config directory and the relative path of the socket
3571                config_dir.push(path);
3572                // canonicalize to remove double dots like /path/to/config/directory/../../path/to/socket/directory/
3573                config_dir.canonicalize().map_err(|io_error| {
3574                    ConfigError::SocketPathError(format!(
3575                        "Could not canonicalize path {config_dir:?}: {io_error}"
3576                    ))
3577                })?
3578            }
3579        };
3580
3581        let socket_name = socket_path
3582            .file_name()
3583            .ok_or(ConfigError::SocketPathError(format!(
3584                "could not get command socket file name from {socket_path:?}"
3585            )))?;
3586
3587        // concatenate parent directory and socket file name
3588        socket_parent_dir.push(socket_name);
3589
3590        let command_socket_path = socket_parent_dir
3591            .to_str()
3592            .ok_or(ConfigError::SocketPathError(format!(
3593                "Invalid socket path {socket_parent_dir:?}"
3594            )))?
3595            .to_string();
3596
3597        Ok(command_socket_path)
3598    }
3599
3600    /// Get the path of where the state will be saved
3601    fn saved_state_path(&self) -> Result<Option<String>, ConfigError> {
3602        let path = match self.saved_state.as_ref() {
3603            Some(path) => path,
3604            None => return Ok(None),
3605        };
3606
3607        debug!("saved_stated path in the config: {}", path);
3608        let config_path = PathBuf::from(self.config_path.clone());
3609
3610        debug!("Config path buffer: {:?}", config_path);
3611        let config_dir = config_path
3612            .parent()
3613            .ok_or(ConfigError::SaveStatePath(format!(
3614                "Could get parent directory of config file {config_path:?}"
3615            )))?;
3616
3617        debug!("Config folder: {:?}", config_dir);
3618        if !config_dir.exists() {
3619            create_dir_all(config_dir).map_err(|io_error| {
3620                ConfigError::SaveStatePath(format!(
3621                    "failed to create state parent directory '{config_dir:?}': {io_error}"
3622                ))
3623            })?;
3624        }
3625
3626        let mut saved_state_path_raw = config_dir.to_path_buf();
3627        saved_state_path_raw.push(path);
3628        debug!(
3629            "Looking for saved state on the path {:?}",
3630            saved_state_path_raw
3631        );
3632
3633        match metadata(path) {
3634            Err(err) if matches!(err.kind(), ErrorKind::NotFound) => {
3635                info!("Create an empty state file at '{}'", path);
3636                File::create(path).map_err(|io_error| {
3637                    ConfigError::SaveStatePath(format!(
3638                        "failed to create state file '{path:?}': {io_error}"
3639                    ))
3640                })?;
3641            }
3642            _ => {}
3643        }
3644
3645        saved_state_path_raw.canonicalize().map_err(|io_error| {
3646            ConfigError::SaveStatePath(format!(
3647                "could not get saved state path from config file input {path:?}: {io_error}"
3648            ))
3649        })?;
3650
3651        let stringified_path = saved_state_path_raw
3652            .to_str()
3653            .ok_or(ConfigError::SaveStatePath(format!(
3654                "Invalid path {saved_state_path_raw:?}"
3655            )))?
3656            .to_string();
3657
3658        Ok(Some(stringified_path))
3659    }
3660
3661    /// read any file to a string
3662    pub fn load_file(path: &str) -> Result<String, ConfigError> {
3663        std::fs::read_to_string(path).map_err(|io_error| ConfigError::FileRead {
3664            path_to_read: path.to_owned(),
3665            io_error,
3666        })
3667    }
3668
3669    /// read any file to bytes
3670    pub fn load_file_bytes(path: &str) -> Result<Vec<u8>, ConfigError> {
3671        std::fs::read(path).map_err(|io_error| ConfigError::FileRead {
3672            path_to_read: path.to_owned(),
3673            io_error,
3674        })
3675    }
3676}
3677
3678impl fmt::Debug for Config {
3679    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3680        f.debug_struct("Config")
3681            .field("config_path", &self.config_path)
3682            .field("command_socket", &self.command_socket)
3683            .field("command_buffer_size", &self.command_buffer_size)
3684            .field("max_command_buffer_size", &self.max_command_buffer_size)
3685            .field("max_connections", &self.max_connections)
3686            .field("min_buffers", &self.min_buffers)
3687            .field("max_buffers", &self.max_buffers)
3688            .field("buffer_size", &self.buffer_size)
3689            .field("saved_state", &self.saved_state)
3690            .field("automatic_state_save", &self.automatic_state_save)
3691            .field("log_level", &self.log_level)
3692            .field("log_target", &self.log_target)
3693            .field("access_logs_target", &self.access_logs_target)
3694            .field("audit_logs_target", &self.audit_logs_target)
3695            .field("audit_logs_json_target", &self.audit_logs_json_target)
3696            .field("access_logs_format", &self.access_logs_format)
3697            .field("worker_count", &self.worker_count)
3698            .field("worker_automatic_restart", &self.worker_automatic_restart)
3699            .field("metrics", &self.metrics)
3700            .field("disable_cluster_metrics", &self.disable_cluster_metrics)
3701            .field("handle_process_affinity", &self.handle_process_affinity)
3702            .field("ctl_command_timeout", &self.ctl_command_timeout)
3703            .field("pid_file_path", &self.pid_file_path)
3704            .field("activate_listeners", &self.activate_listeners)
3705            .field("front_timeout", &self.front_timeout)
3706            .field("back_timeout", &self.back_timeout)
3707            .field("connect_timeout", &self.connect_timeout)
3708            .field("zombie_check_interval", &self.zombie_check_interval)
3709            .field("accept_queue_timeout", &self.accept_queue_timeout)
3710            .field("evict_on_queue_full", &self.evict_on_queue_full)
3711            .field("request_timeout", &self.request_timeout)
3712            .field("worker_timeout", &self.worker_timeout)
3713            .finish()
3714    }
3715}
3716
3717fn display_toml_error(file: &str, error: &toml::de::Error) {
3718    println!("error parsing the configuration file '{file}': {error}");
3719    if let Some(Range { start, end }) = error.span() {
3720        print!("error parsing the configuration file '{file}' at position: {start}, {end}");
3721    }
3722}
3723
3724impl ServerConfig {
3725    /// Default number of slab entries per connection. Set to 4 to accommodate
3726    /// H2 multiplexing (1 frontend + up to 3 backend connections per
3727    /// frontend with stream multiplexing). Previous value was 2 for H1-only
3728    /// operation. Operators with topologies that fan out across more
3729    /// clusters per session can override via `slab_entries_per_connection`
3730    /// in the config (clamped to [2, 32]).
3731    pub const DEFAULT_SLAB_ENTRIES_PER_CONNECTION: u64 = 4;
3732    /// Lower bound for the runtime knob. Below 2 the slab cannot hold one
3733    /// frontend + one backend per session.
3734    pub const MIN_SLAB_ENTRIES_PER_CONNECTION: u64 = 2;
3735    /// Upper bound for the runtime knob. 32 caps memory blow-up from a
3736    /// runaway config; 32 backends per frontend covers any sane topology.
3737    pub const MAX_SLAB_ENTRIES_PER_CONNECTION: u64 = 32;
3738
3739    /// Effective slab-entries-per-connection. Applies the [MIN, MAX] clamp
3740    /// and falls back to the default when the proto field is absent or 0.
3741    pub fn effective_slab_entries_per_connection(&self) -> u64 {
3742        let effective = match self.slab_entries_per_connection {
3743            Some(0) | None => Self::DEFAULT_SLAB_ENTRIES_PER_CONNECTION,
3744            Some(n) => n.clamp(
3745                Self::MIN_SLAB_ENTRIES_PER_CONNECTION,
3746                Self::MAX_SLAB_ENTRIES_PER_CONNECTION,
3747            ),
3748        };
3749        // POST: the effective value is always inside the documented clamp
3750        // window [MIN, MAX], regardless of the raw config input. The default
3751        // itself sits inside that window, so every branch satisfies the bound.
3752        debug_assert!(
3753            (Self::MIN_SLAB_ENTRIES_PER_CONNECTION..=Self::MAX_SLAB_ENTRIES_PER_CONNECTION)
3754                .contains(&effective),
3755            "effective slab entries per connection must stay within [MIN, MAX]"
3756        );
3757        effective
3758    }
3759
3760    /// Size of the slab for the Session manager.
3761    ///
3762    /// With HTTP/2 multiplexing, each frontend session can have multiple backend
3763    /// connections (one per cluster), so we allocate
3764    /// [`Self::effective_slab_entries_per_connection`] entries per connection
3765    /// instead of the old H1-only multiplier of 2.
3766    pub fn slab_capacity(&self) -> u64 {
3767        let per_conn = self.effective_slab_entries_per_connection();
3768        let capacity = 10 + per_conn * self.max_connections;
3769        // POST: the slab always reserves the 10-entry base (listeners, command
3770        // channel, etc.) plus at least MIN entries per connection, so it can
3771        // never be smaller than the base. Strict `>` for any non-zero
3772        // max_connections since per_conn >= MIN >= 2.
3773        debug_assert!(
3774            capacity >= 10,
3775            "slab capacity must reserve the base entries"
3776        );
3777        debug_assert!(
3778            self.max_connections == 0 || capacity > 10,
3779            "a non-zero connection cap must reserve per-connection slab entries"
3780        );
3781        capacity
3782    }
3783}
3784
3785/// reduce the config to the bare minimum needed by a worker
3786impl From<&Config> for ServerConfig {
3787    fn from(config: &Config) -> Self {
3788        let metrics = config.metrics.clone().map(|m| ServerMetricsConfig {
3789            address: m.address.to_string(),
3790            tagged_metrics: m.tagged_metrics,
3791            prefix: m.prefix,
3792            detail: Some(MetricDetail::from(m.detail) as i32),
3793        });
3794        let server_config = Self {
3795            max_connections: config.max_connections as u64,
3796            front_timeout: config.front_timeout,
3797            back_timeout: config.back_timeout,
3798            connect_timeout: config.connect_timeout,
3799            zombie_check_interval: config.zombie_check_interval,
3800            accept_queue_timeout: config.accept_queue_timeout,
3801            min_buffers: config.min_buffers,
3802            max_buffers: config.max_buffers,
3803            buffer_size: config.buffer_size,
3804            log_level: config.log_level.clone(),
3805            log_target: config.log_target.clone(),
3806            access_logs_target: config.access_logs_target.clone(),
3807            audit_logs_target: config.audit_logs_target.clone(),
3808            audit_logs_json_target: config.audit_logs_json_target.clone(),
3809            command_buffer_size: config.command_buffer_size,
3810            max_command_buffer_size: config.max_command_buffer_size,
3811            metrics,
3812            access_log_format: ProtobufAccessLogFormat::from(&config.access_logs_format) as i32,
3813            log_colored: config.log_colored,
3814            slab_entries_per_connection: config.slab_entries_per_connection,
3815            basic_auth_max_credential_bytes: config.basic_auth_max_credential_bytes,
3816            evict_on_queue_full: Some(config.evict_on_queue_full),
3817            max_connections_per_ip: Some(config.max_connections_per_ip),
3818            retry_after: Some(config.retry_after),
3819            splice_pipe_capacity_bytes: config.splice_pipe_capacity_bytes,
3820        };
3821
3822        // POST: the worker-facing config preserves the buffer-pool invariant
3823        // (min <= max) and carries the sizing knobs through unchanged — a
3824        // worker derives its slab and buffer pool straight from these, so any
3825        // drift here would desynchronize the master's view from the worker's.
3826        debug_assert!(
3827            server_config.min_buffers <= server_config.max_buffers,
3828            "ServerConfig must preserve min_buffers <= max_buffers"
3829        );
3830        debug_assert_eq!(
3831            server_config.buffer_size, config.buffer_size,
3832            "ServerConfig buffer_size must mirror the source config"
3833        );
3834        debug_assert_eq!(
3835            server_config.max_connections, config.max_connections as u64,
3836            "ServerConfig max_connections must mirror the source config"
3837        );
3838        server_config
3839    }
3840}
3841
3842#[cfg(test)]
3843mod tests {
3844    use toml::to_string;
3845
3846    use super::*;
3847
3848    #[test]
3849    fn hsts_to_proto_enabled_substitutes_default_max_age() {
3850        let cfg = FileHstsConfig {
3851            enabled: Some(true),
3852            max_age: None,
3853            include_subdomains: None,
3854            preload: None,
3855            force_replace_backend: None,
3856        };
3857        let proto = cfg.to_proto("test").expect("should validate");
3858        assert_eq!(proto.enabled, Some(true));
3859        assert_eq!(proto.max_age, Some(DEFAULT_HSTS_MAX_AGE));
3860    }
3861
3862    #[test]
3863    fn hsts_to_proto_explicit_max_age_kept() {
3864        let cfg = FileHstsConfig {
3865            enabled: Some(true),
3866            max_age: Some(63_072_000),
3867            include_subdomains: Some(true),
3868            preload: Some(true),
3869            force_replace_backend: None,
3870        };
3871        let proto = cfg.to_proto("test").expect("should validate");
3872        assert_eq!(proto.max_age, Some(63_072_000));
3873        assert_eq!(proto.include_subdomains, Some(true));
3874        assert_eq!(proto.preload, Some(true));
3875    }
3876
3877    #[test]
3878    fn hsts_to_proto_disabled_keeps_zero_intent() {
3879        // `enabled = false` means "explicit disable" — the materialiser
3880        // in `Frontend::new` won't append an edit, so the proto still
3881        // round-trips with `enabled = Some(false)`.
3882        let cfg = FileHstsConfig {
3883            enabled: Some(false),
3884            max_age: None,
3885            include_subdomains: None,
3886            preload: None,
3887            force_replace_backend: None,
3888        };
3889        let proto = cfg.to_proto("test").expect("should validate");
3890        assert_eq!(proto.enabled, Some(false));
3891    }
3892
3893    #[test]
3894    fn hsts_to_proto_kill_switch_max_age_zero_allowed() {
3895        // RFC 6797 §11.4: `max-age=0` instructs the UA to "cease
3896        // regarding the host as a Known HSTS Host". Explicit operator
3897        // intent — must NOT warn or fail.
3898        let cfg = FileHstsConfig {
3899            enabled: Some(true),
3900            max_age: Some(0),
3901            include_subdomains: None,
3902            preload: None,
3903            force_replace_backend: None,
3904        };
3905        let proto = cfg.to_proto("test").expect("kill-switch must validate");
3906        assert_eq!(proto.max_age, Some(0));
3907    }
3908
3909    #[test]
3910    fn hsts_to_proto_missing_enabled_errors() {
3911        let cfg = FileHstsConfig {
3912            enabled: None,
3913            max_age: Some(31_536_000),
3914            include_subdomains: None,
3915            preload: None,
3916            force_replace_backend: None,
3917        };
3918        match cfg.to_proto("test").unwrap_err() {
3919            ConfigError::HstsEnabledRequired(scope) => assert_eq!(scope, "test"),
3920            other => panic!("expected HstsEnabledRequired, got {other:?}"),
3921        }
3922    }
3923
3924    #[test]
3925    fn hsts_rejected_on_http_listener() {
3926        // RFC 6797 §7.2: an [hsts] block on an HTTP listener must be
3927        // rejected at TOML config-load — `HttpListenerConfig` carries no
3928        // `hsts` field and silently dropping the operator's intent
3929        // would be a worse failure mode than a typed error.
3930        let mut listener = ListenerBuilder::new(
3931            SocketAddress::new_v4(127, 0, 0, 1, 8080),
3932            ListenerProtocol::Http,
3933        );
3934        listener.hsts = Some(FileHstsConfig {
3935            enabled: Some(true),
3936            max_age: Some(31_536_000),
3937            include_subdomains: None,
3938            preload: None,
3939            force_replace_backend: None,
3940        });
3941        match listener.to_http(None).unwrap_err() {
3942            ConfigError::HstsOnPlainHttp(scope) => assert!(
3943                scope.contains("HTTP listener"),
3944                "expected scope to mention 'HTTP listener', got {scope:?}"
3945            ),
3946            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
3947        }
3948    }
3949
3950    #[test]
3951    fn hsts_rejected_on_http_frontend() {
3952        // A `FileClusterFrontendConfig` without a key+certificate pair
3953        // generates `RequestType::AddHttpFrontend` in
3954        // `HttpFrontendConfig::generate_requests`. RFC 6797 §7.2 forbids
3955        // HSTS on plaintext HTTP, so an `[hsts]` block on a cert-less
3956        // (HTTP-bound) frontend must be rejected at TOML config-load.
3957        let frontend = FileClusterFrontendConfig {
3958            address: "127.0.0.1:8080".parse().unwrap(),
3959            hostname: Some("example.com".to_owned()),
3960            path: None,
3961            path_type: None,
3962            method: None,
3963            certificate: None,
3964            key: None,
3965            certificate_chain: None,
3966            tls_versions: vec![],
3967            position: RulePosition::Tree,
3968            tags: None,
3969            redirect: None,
3970            redirect_scheme: None,
3971            redirect_template: None,
3972            rewrite_host: None,
3973            rewrite_path: None,
3974            rewrite_port: None,
3975            required_auth: None,
3976            headers: None,
3977            hsts: Some(FileHstsConfig {
3978                enabled: Some(true),
3979                max_age: Some(31_536_000),
3980                include_subdomains: None,
3981                preload: None,
3982                force_replace_backend: None,
3983            }),
3984        };
3985        match frontend.to_http_front("api").unwrap_err() {
3986            ConfigError::HstsOnPlainHttp(scope) => {
3987                assert!(
3988                    scope.contains("api") && scope.contains("example.com"),
3989                    "expected scope to mention 'api' and 'example.com', got {scope:?}"
3990                );
3991            }
3992            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
3993        }
3994    }
3995
3996    #[test]
3997    fn serialize() {
3998        let http = ListenerBuilder::new(
3999            SocketAddress::new_v4(127, 0, 0, 1, 8080),
4000            ListenerProtocol::Http,
4001        )
4002        .with_answer_404_path(Some("404.html"))
4003        .to_owned();
4004        println!("http: {:?}", to_string(&http));
4005
4006        let https = ListenerBuilder::new(
4007            SocketAddress::new_v4(127, 0, 0, 1, 8443),
4008            ListenerProtocol::Https,
4009        )
4010        .with_answer_404_path(Some("404.html"))
4011        .to_owned();
4012        println!("https: {:?}", to_string(&https));
4013
4014        let listeners = vec![http, https];
4015        let config = FileConfig {
4016            command_socket: Some(String::from("./command_folder/sock")),
4017            worker_count: Some(2),
4018            worker_automatic_restart: Some(true),
4019            max_connections: Some(500),
4020            min_buffers: Some(1),
4021            max_buffers: Some(500),
4022            buffer_size: Some(16393),
4023            metrics: Some(MetricsConfig {
4024                address: "127.0.0.1:8125".parse().unwrap(),
4025                tagged_metrics: false,
4026                prefix: Some(String::from("sozu-metrics")),
4027                detail: MetricDetailLevel::default(),
4028            }),
4029            listeners: Some(listeners),
4030            ..Default::default()
4031        };
4032
4033        println!("config: {:?}", to_string(&config));
4034        let encoded = to_string(&config).unwrap();
4035        println!("conf:\n{encoded}");
4036    }
4037
4038    #[test]
4039    fn parse() {
4040        let path = "assets/config.toml";
4041        let config = Config::load_from_path(path).unwrap_or_else(|load_error| {
4042            panic!("Cannot load config from path {path}: {load_error:?}")
4043        });
4044        println!("config: {config:#?}");
4045        //panic!();
4046    }
4047
4048    #[test]
4049    fn multiple_listeners_preserve_per_address_expect_proxy() {
4050        let toml_content = r#"
4051            command_socket = "/tmp/sozu_test.sock"
4052            worker_count = 1
4053
4054            [[listeners]]
4055            protocol = "http"
4056            address = "172.16.20.1:80"
4057            expect_proxy = true
4058
4059            [[listeners]]
4060            protocol = "http"
4061            address = "10.22.0.1:80"
4062            expect_proxy = false
4063
4064            [[listeners]]
4065            protocol = "https"
4066            address = "192.168.1.1:443"
4067            expect_proxy = true
4068
4069            [[listeners]]
4070            protocol = "https"
4071            address = "192.168.2.1:443"
4072            expect_proxy = false
4073        "#;
4074
4075        let file_config: FileConfig =
4076            toml::from_str(toml_content).expect("Could not parse TOML config");
4077
4078        let listeners = file_config.listeners.as_ref().expect("No listeners found");
4079        assert_eq!(listeners.len(), 4);
4080
4081        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
4082            .into_config()
4083            .expect("Could not build config");
4084
4085        assert_eq!(config.http_listeners.len(), 2);
4086        assert_eq!(config.https_listeners.len(), 2);
4087
4088        // HTTP listeners
4089        let http_proxy = config
4090            .http_listeners
4091            .iter()
4092            .find(|l| SocketAddr::from(l.address) == "172.16.20.1:80".parse().unwrap())
4093            .expect("Listener on 172.16.20.1:80 not found");
4094        let http_direct = config
4095            .http_listeners
4096            .iter()
4097            .find(|l| SocketAddr::from(l.address) == "10.22.0.1:80".parse().unwrap())
4098            .expect("Listener on 10.22.0.1:80 not found");
4099
4100        assert!(http_proxy.expect_proxy);
4101        assert!(!http_direct.expect_proxy);
4102
4103        // HTTPS listeners
4104        let https_proxy = config
4105            .https_listeners
4106            .iter()
4107            .find(|l| SocketAddr::from(l.address) == "192.168.1.1:443".parse().unwrap())
4108            .expect("Listener on 192.168.1.1:443 not found");
4109        let https_direct = config
4110            .https_listeners
4111            .iter()
4112            .find(|l| SocketAddr::from(l.address) == "192.168.2.1:443".parse().unwrap())
4113            .expect("Listener on 192.168.2.1:443 not found");
4114
4115        assert!(https_proxy.expect_proxy);
4116        assert!(!https_direct.expect_proxy);
4117    }
4118
4119    #[test]
4120    fn multiple_listeners_generate_correct_worker_requests() {
4121        let toml_content = r#"
4122            command_socket = "/tmp/sozu_test.sock"
4123            worker_count = 1
4124            activate_listeners = true
4125
4126            [[listeners]]
4127            protocol = "http"
4128            address = "172.16.20.1:80"
4129            expect_proxy = true
4130
4131            [[listeners]]
4132            protocol = "http"
4133            address = "10.22.0.1:80"
4134            expect_proxy = false
4135        "#;
4136
4137        let file_config: FileConfig =
4138            toml::from_str(toml_content).expect("Could not parse TOML config");
4139
4140        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
4141            .into_config()
4142            .expect("Could not build config");
4143
4144        let messages = config
4145            .generate_config_messages()
4146            .expect("Could not generate config messages");
4147
4148        let add_listener_count = messages
4149            .iter()
4150            .filter(|m| {
4151                matches!(
4152                    m.content.request_type,
4153                    Some(RequestType::AddHttpListener(_))
4154                )
4155            })
4156            .count();
4157
4158        let activate_listener_count = messages
4159            .iter()
4160            .filter(|m| {
4161                matches!(
4162                    m.content.request_type,
4163                    Some(RequestType::ActivateListener(ActivateListener {
4164                        proxy,
4165                        ..
4166                    })) if proxy == ListenerType::Http as i32
4167                )
4168            })
4169            .count();
4170
4171        assert_eq!(add_listener_count, 2);
4172        assert_eq!(activate_listener_count, 2);
4173    }
4174
4175    #[test]
4176    fn documented_udp_dns_example_loads_and_emits_udp_requests() {
4177        // The DNS example from doc/configure.md ("#### UDP clusters"): a
4178        // `protocol = "udp"` listener + a `protocol = "tcp"` cluster whose
4179        // frontend points at that UDP listener address, with datagram knobs
4180        // under `[clusters.dns.udp]`. This must load without a
4181        // `WrongFrontendProtocol` error and emit an `AddUdpListener` and an
4182        // `AddUdpFrontend` (not `AddTcpFrontend`).
4183        let toml_content = r#"
4184            command_socket = "/tmp/sozu_test.sock"
4185            worker_count = 1
4186            activate_listeners = true
4187
4188            [[listeners]]
4189            protocol = "udp"
4190            address  = "0.0.0.0:53"
4191
4192            [clusters.dns]
4193            protocol       = "tcp"
4194            load_balancing = "HRW"
4195            frontends = [
4196              { address = "0.0.0.0:53" }
4197            ]
4198            backends = [
4199              { address = "10.0.0.10:53" },
4200              { address = "10.0.0.11:53" }
4201            ]
4202
4203            [clusters.dns.udp]
4204            affinity_key        = "SOURCE_IP"
4205            responses           = 1
4206            requests            = 0
4207            send_proxy_protocol = true
4208
4209            [clusters.dns.udp.health]
4210            mode      = "TCP_PROBE"
4211            tcp_port  = 53
4212            rise      = 2
4213            fall      = 3
4214            fail_open = true
4215        "#;
4216
4217        let file_config: FileConfig =
4218            toml::from_str(toml_content).expect("Could not parse documented DNS TOML");
4219
4220        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
4221            .into_config()
4222            .expect("documented UDP DNS example must load without WrongFrontendProtocol");
4223
4224        // The UDP listener was registered.
4225        assert_eq!(
4226            config.udp_listeners.len(),
4227            1,
4228            "the protocol=\"udp\" listener must be built"
4229        );
4230
4231        let messages = config
4232            .generate_config_messages()
4233            .expect("Could not generate config messages");
4234
4235        let add_udp_listener_count = messages
4236            .iter()
4237            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpListener(_))))
4238            .count();
4239        assert_eq!(
4240            add_udp_listener_count, 1,
4241            "must emit exactly one AddUdpListener"
4242        );
4243
4244        let add_udp_frontend_count = messages
4245            .iter()
4246            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpFrontend(_))))
4247            .count();
4248        assert_eq!(
4249            add_udp_frontend_count, 1,
4250            "the cluster frontend on the UDP listener must emit AddUdpFrontend"
4251        );
4252
4253        // It must NOT have been emitted as a TCP frontend.
4254        let add_tcp_frontend_count = messages
4255            .iter()
4256            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddTcpFrontend(_))))
4257            .count();
4258        assert_eq!(
4259            add_tcp_frontend_count, 0,
4260            "a UDP-listener-addressed frontend must not be emitted as AddTcpFrontend"
4261        );
4262
4263        // The AddUdpFrontend carries the cluster id and address.
4264        let udp_frontend = messages
4265            .iter()
4266            .find_map(|m| match &m.content.request_type {
4267                Some(RequestType::AddUdpFrontend(f)) => Some(f),
4268                _ => None,
4269            })
4270            .expect("AddUdpFrontend must be present");
4271        assert_eq!(udp_frontend.cluster_id, "dns");
4272        assert_eq!(
4273            SocketAddr::from(udp_frontend.address),
4274            "0.0.0.0:53".parse().unwrap()
4275        );
4276
4277        // The UDP cluster knobs from [clusters.dns.udp] survive onto the
4278        // AddCluster request.
4279        let cluster = messages
4280            .iter()
4281            .find_map(|m| match &m.content.request_type {
4282                Some(RequestType::AddCluster(c)) if c.cluster_id == "dns" => Some(c),
4283                _ => None,
4284            })
4285            .expect("AddCluster for 'dns' must be present");
4286        let udp = cluster
4287            .udp
4288            .as_ref()
4289            .expect("[clusters.dns.udp] block must carry onto the cluster");
4290        assert_eq!(udp.responses, Some(1));
4291    }
4292
4293    #[test]
4294    fn duplicate_listener_address_rejected() {
4295        let toml_content = r#"
4296            command_socket = "/tmp/sozu_test.sock"
4297            worker_count = 1
4298
4299            [[listeners]]
4300            protocol = "http"
4301            address = "0.0.0.0:80"
4302
4303            [[listeners]]
4304            protocol = "http"
4305            address = "0.0.0.0:80"
4306        "#;
4307
4308        let file_config: FileConfig =
4309            toml::from_str(toml_content).expect("Could not parse TOML config");
4310
4311        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
4312
4313        assert!(
4314            result.is_err(),
4315            "Should reject duplicate listener addresses"
4316        );
4317    }
4318
4319    #[test]
4320    fn buffer_size_below_h2_minimum_rejected() {
4321        // Default ALPN ["h2", "http/1.1"] + buffer_size = 8192 must error.
4322        let toml_content = r#"
4323            command_socket = "/tmp/sozu_test.sock"
4324            worker_count = 1
4325            buffer_size = 8192
4326
4327            [[listeners]]
4328            protocol = "https"
4329            address = "127.0.0.1:8443"
4330        "#;
4331        let file_config: FileConfig =
4332            toml::from_str(toml_content).expect("Could not parse TOML config");
4333        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
4334        match result {
4335            Err(ConfigError::BufferSizeTooSmallForH2 {
4336                buffer_size: 8192,
4337                minimum: 16_393,
4338                listeners: 1,
4339            }) => {}
4340            other => panic!("expected BufferSizeTooSmallForH2, got {other:?}"),
4341        }
4342    }
4343
4344    #[test]
4345    fn buffer_size_below_h2_minimum_accepted_when_no_h2_listener() {
4346        // Drop "h2" from ALPN — buffer_size = 8192 is now valid.
4347        let toml_content = r#"
4348            command_socket = "/tmp/sozu_test.sock"
4349            worker_count = 1
4350            buffer_size = 8192
4351
4352            [[listeners]]
4353            protocol = "https"
4354            address = "127.0.0.1:8443"
4355            alpn_protocols = ["http/1.1"]
4356        "#;
4357        let file_config: FileConfig =
4358            toml::from_str(toml_content).expect("Could not parse TOML config");
4359        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
4360        assert!(
4361            result.is_ok(),
4362            "non-H2 HTTPS listener with sub-16393 buffer should be accepted: {result:?}"
4363        );
4364    }
4365
4366    #[test]
4367    fn buffer_size_at_h2_minimum_accepted() {
4368        let toml_content = r#"
4369            command_socket = "/tmp/sozu_test.sock"
4370            worker_count = 1
4371            buffer_size = 16393
4372
4373            [[listeners]]
4374            protocol = "https"
4375            address = "127.0.0.1:8443"
4376        "#;
4377        let file_config: FileConfig =
4378            toml::from_str(toml_content).expect("Could not parse TOML config");
4379        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
4380        assert!(
4381            result.is_ok(),
4382            "buffer_size at the H2 minimum should be accepted: {result:?}"
4383        );
4384    }
4385
4386    #[test]
4387    fn alpn_protocols_default() {
4388        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4389        let config = builder.to_tls(None).expect("to_tls should succeed");
4390        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
4391    }
4392
4393    #[test]
4394    fn alpn_protocols_custom() {
4395        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4396        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned()]));
4397        let config = builder.to_tls(None).expect("to_tls should succeed");
4398        assert_eq!(config.alpn_protocols, vec!["http/1.1"]);
4399    }
4400
4401    #[test]
4402    fn alpn_protocols_invalid_rejected() {
4403        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4404        builder.with_alpn_protocols(Some(vec!["h3".to_owned()]));
4405        let result = builder.to_tls(None);
4406        assert!(result.is_err());
4407        let err = result.unwrap_err();
4408        assert!(
4409            err.to_string().contains("h3"),
4410            "error should mention the invalid protocol: {err}"
4411        );
4412    }
4413
4414    #[test]
4415    fn alpn_protocols_empty_uses_default() {
4416        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4417        builder.with_alpn_protocols(Some(vec![]));
4418        let config = builder.to_tls(None).expect("to_tls should succeed");
4419        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
4420    }
4421
4422    #[test]
4423    fn alpn_protocols_deduplicated() {
4424        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4425        builder.with_alpn_protocols(Some(vec![
4426            "h2".to_owned(),
4427            "h2".to_owned(),
4428            "http/1.1".to_owned(),
4429        ]));
4430        let config = builder.to_tls(None).expect("to_tls should succeed");
4431        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
4432    }
4433
4434    #[test]
4435    fn alpn_protocols_order_preserved() {
4436        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4437        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned(), "h2".to_owned()]));
4438        let config = builder.to_tls(None).expect("to_tls should succeed");
4439        assert_eq!(config.alpn_protocols, vec!["http/1.1", "h2"]);
4440    }
4441
4442    /// CRLF or NUL in a `[[clusters.<id>.frontends.headers]]` value
4443    /// would let an operator-supplied config splice arbitrary
4444    /// header / request lines into the H1 wire on the backend side
4445    /// (CWE-113). The H2 emission path filters at runtime; we reject
4446    /// at config-load time as a defense in depth.
4447    #[test]
4448    fn parse_header_edit_rejects_crlf_in_value() {
4449        let entry = HeaderEditConfig {
4450            position: "request".to_owned(),
4451            key: "X-Test".to_owned(),
4452            value: "value\r\nEvil-Header: stolen".to_owned(),
4453        };
4454        let err = parse_header_edit(0, &entry).expect_err("CRLF in value must be rejected");
4455        match err {
4456            ConfigError::InvalidHeaderBytes { index, field } => {
4457                assert_eq!(index, 0);
4458                assert_eq!(field, "value");
4459            }
4460            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
4461        }
4462    }
4463
4464    #[test]
4465    fn parse_header_edit_rejects_lf_in_key() {
4466        let entry = HeaderEditConfig {
4467            position: "response".to_owned(),
4468            key: "X-\nTest".to_owned(),
4469            value: "ok".to_owned(),
4470        };
4471        let err = parse_header_edit(2, &entry).expect_err("LF in key must be rejected");
4472        match err {
4473            ConfigError::InvalidHeaderBytes { index, field } => {
4474                assert_eq!(index, 2);
4475                assert_eq!(field, "key");
4476            }
4477            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
4478        }
4479    }
4480
4481    #[test]
4482    fn parse_header_edit_rejects_nul() {
4483        let entry = HeaderEditConfig {
4484            position: "both".to_owned(),
4485            key: "X-Test".to_owned(),
4486            value: "with\0nul".to_owned(),
4487        };
4488        assert!(matches!(
4489            parse_header_edit(0, &entry),
4490            Err(ConfigError::InvalidHeaderBytes { .. })
4491        ));
4492    }
4493
4494    /// Horizontal tab `\t` (0x09) is permitted in field values per
4495    /// RFC 9110 §5.5 (folded-header obs-fold parts). The value-side
4496    /// validator must NOT reject it — otherwise legitimate operator
4497    /// configs (e.g. `Authorization: Basic\tCREDENTIALS`) become
4498    /// unusable. The key-side validator IS stricter (token grammar).
4499    #[test]
4500    fn parse_header_edit_accepts_tab_in_value() {
4501        let entry = HeaderEditConfig {
4502            position: "request".to_owned(),
4503            key: "X-Test".to_owned(),
4504            value: "with\ttab".to_owned(),
4505        };
4506        let header = parse_header_edit(0, &entry).expect("tab in value must be accepted");
4507        assert_eq!(header.val, "with\ttab");
4508    }
4509
4510    /// Header NAMES follow `token` grammar per RFC 9110 §5.1. HTAB and
4511    /// SP are NOT tchar; the key-side validator must reject them even
4512    /// though the value-side validator permits HTAB. Without this,
4513    /// an operator entry like `key = "Host\t"` would emit `Host\t: …`
4514    /// on the H1 wire and produce an invalid (but parser-tolerant)
4515    /// header line that some backends silently accept as `Host:`.
4516    #[test]
4517    fn parse_header_edit_rejects_tab_in_key() {
4518        let entry = HeaderEditConfig {
4519            position: "request".to_owned(),
4520            key: "Host\t".to_owned(),
4521            value: "ok".to_owned(),
4522        };
4523        let err = parse_header_edit(0, &entry).expect_err("HTAB in key must be rejected");
4524        match err {
4525            ConfigError::InvalidHeaderBytes { field, .. } => assert_eq!(field, "key"),
4526            other => panic!("expected InvalidHeaderBytes{{field=\"key\"}}, got {other:?}"),
4527        }
4528    }
4529
4530    #[test]
4531    fn parse_header_edit_rejects_space_in_key() {
4532        let entry = HeaderEditConfig {
4533            position: "request".to_owned(),
4534            key: "X Test".to_owned(),
4535            value: "ok".to_owned(),
4536        };
4537        let err = parse_header_edit(0, &entry).expect_err("SP in key must be rejected");
4538        assert!(matches!(err, ConfigError::InvalidHeaderBytes { .. }));
4539    }
4540
4541    #[test]
4542    fn parse_header_edit_rejects_empty_key() {
4543        let entry = HeaderEditConfig {
4544            position: "request".to_owned(),
4545            key: String::new(),
4546            value: "ok".to_owned(),
4547        };
4548        let err = parse_header_edit(0, &entry).expect_err("empty key must be rejected");
4549        assert!(matches!(
4550            err,
4551            ConfigError::InvalidHeaderBytes { field: "key", .. }
4552        ));
4553    }
4554
4555    #[test]
4556    fn parse_header_edit_accepts_clean_value() {
4557        let entry = HeaderEditConfig {
4558            position: "request".to_owned(),
4559            key: "X-Tenant".to_owned(),
4560            value: "alpha".to_owned(),
4561        };
4562        let header = parse_header_edit(0, &entry).expect("clean value must be accepted");
4563        assert_eq!(header.key, "X-Tenant");
4564        assert_eq!(header.val, "alpha");
4565    }
4566
4567    /// A bare string with no scheme prefix is the inline literal body.
4568    /// This is the common case — short canned responses inline in TOML
4569    /// or a `--answer` flag, no disk I/O.
4570    #[test]
4571    fn resolve_answer_source_bare_string_is_literal() {
4572        let body = resolve_answer_source("HTTP/1.1 503 Service Unavailable\r\n\r\nbusy")
4573            .expect("bare-string source must resolve");
4574        assert_eq!(body, "HTTP/1.1 503 Service Unavailable\r\n\r\nbusy");
4575    }
4576
4577    #[test]
4578    fn resolve_answer_source_empty_string_is_legitimate() {
4579        let body = resolve_answer_source("").expect("empty source must resolve");
4580        assert_eq!(body, "");
4581    }
4582
4583    /// `file://` opts into reading the path off disk. A non-existent
4584    /// path bubbles up as `ConfigError::FileOpen` so the operator gets
4585    /// the same diagnostics as the existing per-status `answer_NNN`
4586    /// flow.
4587    #[test]
4588    fn resolve_answer_source_file_scheme_missing_file_errors() {
4589        let err = resolve_answer_source("file:///nonexistent/sozu-test/never.http")
4590            .expect_err("missing path must error");
4591        assert!(matches!(err, ConfigError::FileOpen { .. }));
4592    }
4593
4594    /// `file://` strips the scheme; an empty path after the scheme is
4595    /// rejected (empty path on filesystem read).
4596    #[test]
4597    fn resolve_answer_source_file_scheme_empty_path_errors() {
4598        let err = resolve_answer_source("file://").expect_err("empty path must error");
4599        assert!(matches!(err, ConfigError::FileOpen { .. }));
4600    }
4601}