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            if let Some(soft_limit) = soft_rlimit_nofile() {
1207                let advisory = soft_limit.saturating_mul(7) / 10;
1208                if u64::from(max_flows) > advisory {
1209                    warn!(
1210                        "UDP listener {}: max_flows = {} exceeds ~70% of the soft RLIMIT_NOFILE ({}); \
1211                         per-flow connected sockets may hit EMFILE",
1212                        self.address, max_flows, advisory
1213                    );
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")]
1367pub enum MetricDetailLevel {
1368    Process,
1369    Frontend,
1370    Cluster,
1371    Backend,
1372}
1373
1374impl Default for MetricDetailLevel {
1375    fn default() -> Self {
1376        // Preserve the historical (pre-knob) behaviour: cluster-scoped
1377        // metrics are emitted by default.
1378        Self::Cluster
1379    }
1380}
1381
1382impl From<MetricDetailLevel> for MetricDetail {
1383    fn from(level: MetricDetailLevel) -> Self {
1384        match level {
1385            MetricDetailLevel::Process => MetricDetail::DetailProcess,
1386            MetricDetailLevel::Frontend => MetricDetail::DetailFrontend,
1387            MetricDetailLevel::Cluster => MetricDetail::DetailCluster,
1388            MetricDetailLevel::Backend => MetricDetail::DetailBackend,
1389        }
1390    }
1391}
1392
1393impl From<MetricDetail> for MetricDetailLevel {
1394    /// Reverse of [`From<MetricDetailLevel> for MetricDetail`] — used by the
1395    /// worker side to convert the protobuf wire enum back into the
1396    /// configuration enum before passing it to `sozu_lib::metrics::setup`.
1397    fn from(detail: MetricDetail) -> Self {
1398        match detail {
1399            MetricDetail::DetailProcess => MetricDetailLevel::Process,
1400            MetricDetail::DetailFrontend => MetricDetailLevel::Frontend,
1401            MetricDetail::DetailCluster => MetricDetailLevel::Cluster,
1402            MetricDetail::DetailBackend => MetricDetailLevel::Backend,
1403        }
1404    }
1405}
1406
1407#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1408#[serde(deny_unknown_fields)]
1409pub struct MetricsConfig {
1410    pub address: SocketAddr,
1411    #[serde(default)]
1412    pub tagged_metrics: bool,
1413    #[serde(default)]
1414    pub prefix: Option<String>,
1415    /// Cardinality knob for label-aware metrics. Defaults to `cluster` to
1416    /// preserve historical behaviour. See [`MetricDetailLevel`].
1417    #[serde(default)]
1418    pub detail: MetricDetailLevel,
1419}
1420
1421#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1422#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
1423#[serde(deny_unknown_fields)]
1424pub enum PathRuleType {
1425    Prefix,
1426    Regex,
1427    Equals,
1428}
1429
1430#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1431#[serde(deny_unknown_fields)]
1432pub struct FileClusterFrontendConfig {
1433    pub address: SocketAddr,
1434    pub hostname: Option<String>,
1435    /// creates a path routing rule where the request URL path has to match this
1436    pub path: Option<String>,
1437    /// declares whether the path rule is Prefix (default), Regex, or Equals
1438    pub path_type: Option<PathRuleType>,
1439    pub method: Option<String>,
1440    pub certificate: Option<String>,
1441    pub key: Option<String>,
1442    pub certificate_chain: Option<String>,
1443    #[serde(default)]
1444    pub tls_versions: Vec<TlsVersion>,
1445    #[serde(default)]
1446    pub position: RulePosition,
1447    pub tags: Option<BTreeMap<String, String>>,
1448    /// Frontend-level redirect policy. Accepted values are `forward`
1449    /// (default — route to the backend), `permanent` (return 301 with the
1450    /// computed `Location`), or `unauthorized` (return 401 with
1451    /// `WWW-Authenticate: Basic realm=…`). Case-insensitive.
1452    pub redirect: Option<String>,
1453    /// Scheme used when emitting a permanent redirect's `Location`. Accepted
1454    /// values are `use-same` (default — preserve request scheme), `use-http`,
1455    /// `use-https`. Case-insensitive.
1456    pub redirect_scheme: Option<String>,
1457    /// Optional template applied to the emitted permanent-redirect response
1458    /// body. Supports the `%REDIRECT_LOCATION` and other variables
1459    /// documented in `doc/configure.md`.
1460    pub redirect_template: Option<String>,
1461    /// Rewrite host template. Supports `$HOST[n]` / `$PATH[n]` placeholders
1462    /// populated from regex captures collected during routing.
1463    pub rewrite_host: Option<String>,
1464    /// Rewrite path template. Same grammar as `rewrite_host`.
1465    pub rewrite_path: Option<String>,
1466    /// Optional literal port override on the rewritten URL.
1467    pub rewrite_port: Option<u32>,
1468    /// When true, requests routed through this frontend must carry a valid
1469    /// `Authorization: Basic <user:pass>` header whose hash matches one of
1470    /// the cluster's `authorized_hashes`. Default: false.
1471    pub required_auth: Option<bool>,
1472    /// Header mutations applied to requests and/or responses passing through
1473    /// this frontend. See [`HeaderEditConfig`] for the empty-value-deletes
1474    /// semantics (HAProxy `del-header` parity).
1475    pub headers: Option<Vec<HeaderEditConfig>>,
1476    /// Per-frontend HSTS (RFC 6797) policy. When set, overrides any
1477    /// listener-default HSTS for this frontend. Set `enabled = false`
1478    /// to suppress an inherited listener default. Per RFC 6797 §7.2,
1479    /// HSTS is rejected on plain-HTTP frontends at config-load time.
1480    pub hsts: Option<FileHstsConfig>,
1481}
1482
1483/// A single header mutation as serialised under
1484/// `[[clusters.<id>.frontends.headers]]`. Maps to the proto [`Header`]
1485/// message at request-build time.
1486///
1487/// `position` accepts `request`, `response`, or `both` (case-insensitive).
1488/// An empty `value` deletes the header by name (HAProxy `del-header` parity).
1489#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1490#[serde(deny_unknown_fields)]
1491pub struct HeaderEditConfig {
1492    pub position: String,
1493    pub key: String,
1494    pub value: String,
1495}
1496
1497/// HSTS (HTTP Strict Transport Security, RFC 6797) policy as serialised
1498/// under `[https.listeners.default.hsts]` (listener default) or
1499/// `[clusters.<id>.frontends.hsts]` (per-frontend override).
1500///
1501/// `enabled` is REQUIRED whenever the block is present — its presence vs
1502/// absence disambiguates "preserve current" / "explicit disable" / "enable"
1503/// on hot-reconfig partial updates.
1504///
1505/// When `enabled = true` and `max_age` is omitted, sozu substitutes
1506/// [`DEFAULT_HSTS_MAX_AGE`] (1 year) at config-load time.
1507#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1508#[serde(deny_unknown_fields)]
1509pub struct FileHstsConfig {
1510    /// REQUIRED. `true` enables HSTS for this scope; `false` suppresses
1511    /// any inherited listener default (explicit-disable signal).
1512    pub enabled: Option<bool>,
1513    /// `Strict-Transport-Security: max-age=<seconds>`. Optional —
1514    /// defaults to [`DEFAULT_HSTS_MAX_AGE`] when `enabled = true`.
1515    /// `max_age = 0` is the RFC 6797 §11.4 kill switch and is allowed
1516    /// silently; `0 < max_age < 86400` warns at config-load.
1517    pub max_age: Option<u32>,
1518    /// Append `; includeSubDomains` to the rendered header.
1519    pub include_subdomains: Option<bool>,
1520    /// Append `; preload` to the rendered header. Opt-in only — see RFC
1521    /// 6797 §14.2 and <https://hstspreload.org/>.
1522    pub preload: Option<bool>,
1523    /// Operator opt-in to override any backend-supplied
1524    /// `Strict-Transport-Security` header. RFC 6797 §6.1 default
1525    /// behaviour is to PRESERVE the backend's value (sozu's edit uses
1526    /// `HeaderEditMode::SetIfAbsent`). Set this to `true` to harden a
1527    /// stale or weak upstream HSTS policy centrally — the materialiser
1528    /// then uses `HeaderEditMode::Set`, replacing any backend STS with
1529    /// sozu's rendered value.
1530    pub force_replace_backend: Option<bool>,
1531}
1532
1533impl FileHstsConfig {
1534    /// Validate and convert the file-level [`FileHstsConfig`] into the
1535    /// proto [`HstsConfig`]. `scope` is a human-readable string (e.g.
1536    /// "listener" or "frontend api/example.com") surfaced into errors
1537    /// and warnings so the operator can pinpoint the offending block.
1538    ///
1539    /// Validation:
1540    /// - `enabled` is required when any other field is set
1541    ///   (`HstsEnabledRequired`); the parser returns the typed error so
1542    ///   callers can fail fast.
1543    /// - `enabled = true && max_age = None` substitutes
1544    ///   [`DEFAULT_HSTS_MAX_AGE`].
1545    /// - `0 < max_age < 86400` warns (likely misconfig — sub-day max-age
1546    ///   is useful only for testing).
1547    /// - `preload = true` with `max_age < DEFAULT_HSTS_MAX_AGE` or
1548    ///   `include_subdomains != Some(true)` warns (the Chrome HSTS
1549    ///   preload list will reject the host).
1550    /// - `max_age = 0` is allowed silently (RFC 6797 §11.4 kill switch).
1551    pub fn to_proto(&self, scope: &str) -> Result<HstsConfig, ConfigError> {
1552        let enabled = match self.enabled {
1553            Some(v) => v,
1554            None => return Err(ConfigError::HstsEnabledRequired(scope.to_owned())),
1555        };
1556
1557        let max_age = match (enabled, self.max_age) {
1558            (true, None) => Some(DEFAULT_HSTS_MAX_AGE),
1559            (_, m) => m,
1560        };
1561
1562        if let Some(value) = max_age
1563            && value > 0
1564            && value < 86_400
1565        {
1566            warn!(
1567                "HSTS max_age = {}s on {} is below 1 day — this is almost certainly a \
1568                 misconfiguration. RFC 6797 §11.4 reserves max_age = 0 as the explicit kill \
1569                 switch.",
1570                value, scope
1571            );
1572        }
1573
1574        let include_subdomains = self.include_subdomains;
1575        let preload = self.preload;
1576
1577        if matches!(preload, Some(true)) {
1578            let max_age_value = max_age.unwrap_or(0);
1579            if max_age_value < DEFAULT_HSTS_MAX_AGE {
1580                warn!(
1581                    "HSTS preload = true on {} with max_age = {}s; the Chrome HSTS preload \
1582                     list requires max_age >= {} (https://hstspreload.org/).",
1583                    scope, max_age_value, DEFAULT_HSTS_MAX_AGE
1584                );
1585            }
1586            if include_subdomains != Some(true) {
1587                warn!(
1588                    "HSTS preload = true on {} without include_subdomains = true; the Chrome \
1589                     HSTS preload list requires includeSubDomains \
1590                     (https://hstspreload.org/).",
1591                    scope
1592                );
1593            }
1594        }
1595
1596        let config = HstsConfig {
1597            enabled: Some(enabled),
1598            max_age,
1599            include_subdomains,
1600            preload,
1601            force_replace_backend: self.force_replace_backend,
1602        };
1603
1604        // POST: a built HstsConfig always records an explicit `enabled` flag
1605        // (the `None` case errored above), and an enabled policy always carries
1606        // a max_age — defaulted to DEFAULT_HSTS_MAX_AGE when the operator left
1607        // it unset, so the worker never emits an `max-age`-less STS header.
1608        debug_assert_eq!(
1609            config.enabled,
1610            Some(enabled),
1611            "built HSTS config must record the resolved enabled flag"
1612        );
1613        debug_assert!(
1614            !enabled || config.max_age.is_some(),
1615            "an enabled HSTS policy must carry a max_age"
1616        );
1617        Ok(config)
1618    }
1619}
1620
1621impl FileClusterFrontendConfig {
1622    pub fn to_tcp_front(&self) -> Result<TcpFrontendConfig, ConfigError> {
1623        if self.hostname.is_some() {
1624            return Err(ConfigError::InvalidFrontendConfig("hostname".to_string()));
1625        }
1626        if self.path.is_some() {
1627            return Err(ConfigError::InvalidFrontendConfig(
1628                "path_prefix".to_string(),
1629            ));
1630        }
1631        if self.certificate.is_some() {
1632            return Err(ConfigError::InvalidFrontendConfig(
1633                "certificate".to_string(),
1634            ));
1635        }
1636        if self.hostname.is_some() {
1637            return Err(ConfigError::InvalidFrontendConfig("hostname".to_string()));
1638        }
1639        if self.certificate_chain.is_some() {
1640            return Err(ConfigError::InvalidFrontendConfig(
1641                "certificate_chain".to_string(),
1642            ));
1643        }
1644
1645        let tcp_front = TcpFrontendConfig {
1646            address: self.address,
1647            tags: self.tags.clone(),
1648            // Resolved against `known_addresses` in `populate_clusters`; a
1649            // bare `to_tcp_front` (no listener context) defaults to TCP.
1650            udp: false,
1651        };
1652        // POST: a TCP frontend binds exactly the requested address and carries
1653        // no HTTP-only attributes — the guards above reject hostname / path /
1654        // certificate, so an Ok return is a witness that none leaked through
1655        // (an L7 attribute on an L4 frontend is a config-shape violation).
1656        debug_assert_eq!(
1657            tcp_front.address, self.address,
1658            "TCP frontend must bind the requested address"
1659        );
1660        debug_assert!(
1661            self.hostname.is_none()
1662                && self.path.is_none()
1663                && self.certificate.is_none()
1664                && self.certificate_chain.is_none(),
1665            "a built TCP frontend must carry no HTTP-only attributes"
1666        );
1667        Ok(tcp_front)
1668    }
1669
1670    pub fn to_http_front(&self, _cluster_id: &str) -> Result<HttpFrontendConfig, ConfigError> {
1671        let hostname = match &self.hostname {
1672            Some(hostname) => hostname.to_owned(),
1673            None => {
1674                return Err(ConfigError::Missing(MissingKind::Field(
1675                    "hostname".to_string(),
1676                )));
1677            }
1678        };
1679
1680        let key_opt = match self.key.as_ref() {
1681            None => None,
1682            Some(path) => {
1683                let key = Config::load_file(path)?;
1684                Some(key)
1685            }
1686        };
1687
1688        let certificate_opt = match self.certificate.as_ref() {
1689            None => None,
1690            Some(path) => {
1691                let certificate = Config::load_file(path)?;
1692                Some(certificate)
1693            }
1694        };
1695
1696        let certificate_chain = match self.certificate_chain.as_ref() {
1697            None => None,
1698            Some(path) => {
1699                let certificate_chain = Config::load_file(path)?;
1700                Some(split_certificate_chain(certificate_chain))
1701            }
1702        };
1703
1704        let path = match (self.path.as_ref(), self.path_type.as_ref()) {
1705            (None, _) => PathRule::prefix("".to_string()),
1706            (Some(s), Some(PathRuleType::Prefix)) => PathRule::prefix(s.to_string()),
1707            (Some(s), Some(PathRuleType::Regex)) => PathRule::regex(s.to_string()),
1708            (Some(s), Some(PathRuleType::Equals)) => PathRule::equals(s.to_string()),
1709            (Some(s), None) => PathRule::prefix(s.clone()),
1710        };
1711
1712        let redirect = match self.redirect.as_deref() {
1713            Some(v) => Some(parse_redirect_policy(v)?),
1714            None => None,
1715        };
1716        let redirect_scheme = match self.redirect_scheme.as_deref() {
1717            Some(v) => Some(parse_redirect_scheme(v)?),
1718            None => None,
1719        };
1720
1721        let headers = match self.headers.as_ref() {
1722            Some(entries) => {
1723                let mut out = Vec::with_capacity(entries.len());
1724                for (index, entry) in entries.iter().enumerate() {
1725                    out.push(parse_header_edit(index, entry)?);
1726                }
1727                out
1728            }
1729            None => Vec::new(),
1730        };
1731
1732        // RFC 6797 §7.2: `Strict-Transport-Security` MUST NOT appear on
1733        // plaintext-HTTP responses. A frontend without a key+certificate
1734        // pair generates `RequestType::AddHttpFrontend` in
1735        // `HttpFrontendConfig::generate_requests`, so HSTS configured
1736        // there would silently target an HTTP frontend. Reject at
1737        // config-load before the cert-presence branch can consume it.
1738        let frontend_serves_https = key_opt.is_some() && certificate_opt.is_some();
1739        let hsts = match self.hsts.as_ref() {
1740            Some(h) => {
1741                if !frontend_serves_https {
1742                    return Err(ConfigError::HstsOnPlainHttp(format!(
1743                        "frontend {_cluster_id}/{hostname}"
1744                    )));
1745                }
1746                Some(h.to_proto(&format!("frontend {_cluster_id}/{hostname}"))?)
1747            }
1748            None => None,
1749        };
1750
1751        Ok(HttpFrontendConfig {
1752            address: self.address,
1753            hostname,
1754            certificate: certificate_opt,
1755            key: key_opt,
1756            certificate_chain,
1757            tls_versions: self.tls_versions.clone(),
1758            position: self.position,
1759            path,
1760            method: self.method.clone(),
1761            tags: self.tags.clone(),
1762            redirect,
1763            redirect_scheme,
1764            redirect_template: self.redirect_template.clone(),
1765            rewrite_host: self.rewrite_host.clone(),
1766            rewrite_path: self.rewrite_path.clone(),
1767            rewrite_port: self.rewrite_port,
1768            required_auth: self.required_auth,
1769            headers,
1770            hsts,
1771        })
1772    }
1773}
1774
1775/// Parse a `redirect` TOML value (case-insensitive) into the proto enum.
1776pub(crate) fn parse_redirect_policy(value: &str) -> Result<RedirectPolicy, ConfigError> {
1777    match value.to_ascii_lowercase().as_str() {
1778        "forward" => Ok(RedirectPolicy::Forward),
1779        "permanent" => Ok(RedirectPolicy::Permanent),
1780        "unauthorized" => Ok(RedirectPolicy::Unauthorized),
1781        _ => Err(ConfigError::InvalidRedirectPolicy(value.to_owned())),
1782    }
1783}
1784
1785/// Parse a `redirect_scheme` TOML value (case-insensitive) into the proto enum.
1786pub(crate) fn parse_redirect_scheme(value: &str) -> Result<RedirectScheme, ConfigError> {
1787    match value.to_ascii_lowercase().as_str() {
1788        "use-same" | "use_same" => Ok(RedirectScheme::UseSame),
1789        "use-http" | "use_http" => Ok(RedirectScheme::UseHttp),
1790        "use-https" | "use_https" => Ok(RedirectScheme::UseHttps),
1791        _ => Err(ConfigError::InvalidRedirectScheme(value.to_owned())),
1792    }
1793}
1794
1795/// Parse a `[[clusters.<id>.frontends.headers]]` entry into the proto
1796/// [`Header`] message. `index` is the zero-based position of `entry` in
1797/// the source array — surfaced into the error so a multi-entry config
1798/// pinpoints the bad row instead of just naming the unknown position.
1799/// An empty `value` is the HAProxy `del-header` parity (deletes the
1800/// header by name); the proto carries the empty string verbatim.
1801pub(crate) fn parse_header_edit(
1802    index: usize,
1803    entry: &HeaderEditConfig,
1804) -> Result<Header, ConfigError> {
1805    let position = match entry.position.to_ascii_lowercase().as_str() {
1806        "request" => HeaderPosition::Request,
1807        "response" => HeaderPosition::Response,
1808        "both" => HeaderPosition::Both,
1809        _ => {
1810            return Err(ConfigError::InvalidHeaderPosition {
1811                index,
1812                position: entry.position.clone(),
1813            });
1814        }
1815    };
1816    if !header_name_is_valid_token(entry.key.as_bytes()) {
1817        return Err(ConfigError::InvalidHeaderBytes {
1818            index,
1819            field: "key",
1820        });
1821    }
1822    if header_value_contains_forbidden_controls(entry.value.as_bytes()) {
1823        return Err(ConfigError::InvalidHeaderBytes {
1824            index,
1825            field: "value",
1826        });
1827    }
1828    let header = Header {
1829        position: position as i32,
1830        key: entry.key.clone(),
1831        val: entry.value.clone(),
1832    };
1833    // POST: a Header that escapes this function carries a key that is a valid
1834    // RFC 9110 token and a value free of the forbidden control bytes — the two
1835    // guards above are the sole gate, so an emitted Header can never inject a
1836    // CRLF or a bad token onto the H1 wire (mirrors the runtime filter in
1837    // mux/converter.rs).
1838    debug_assert!(
1839        header_name_is_valid_token(header.key.as_bytes()),
1840        "an emitted header key must be a valid token"
1841    );
1842    debug_assert!(
1843        !header_value_contains_forbidden_controls(header.val.as_bytes()),
1844        "an emitted header value must be free of forbidden control bytes"
1845    );
1846    Ok(header)
1847}
1848
1849/// Field names follow the RFC 9110 §5.1 `token` grammar: non-empty,
1850/// composed of `tchar` bytes (alphanumeric plus a closed punctuation
1851/// list). HTAB and SP are NOT tchar — they belong to field-value
1852/// grammar and must be rejected in the name. Reusing the more
1853/// permissive value-side filter would let `Host\t` slip through and
1854/// produce an invalid header line on the H1 wire (security review
1855/// LISA-002 follow-up).
1856pub(crate) fn header_name_is_valid_token(bytes: &[u8]) -> bool {
1857    if bytes.is_empty() {
1858        return false;
1859    }
1860    bytes.iter().all(|&b| is_tchar(b))
1861}
1862
1863/// `tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
1864/// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA` per RFC 9110 §5.6.2.
1865fn is_tchar(b: u8) -> bool {
1866    b.is_ascii_alphanumeric()
1867        || matches!(
1868            b,
1869            b'!' | b'#'
1870                | b'$'
1871                | b'%'
1872                | b'&'
1873                | b'\''
1874                | b'*'
1875                | b'+'
1876                | b'-'
1877                | b'.'
1878                | b'^'
1879                | b'_'
1880                | b'`'
1881                | b'|'
1882                | b'~'
1883        )
1884}
1885
1886/// Reject any byte that would let a header injection escape the value
1887/// block on the wire (RFC 9110 §5.5 / RFC 9113 §8.2.1):
1888/// `\0..=\x08`, `\x0A..=\x1F`, and `\x7F` — the entire C0 control set
1889/// minus horizontal tab `\x09`, which RFC 9110 explicitly permits in
1890/// field values. Mirrors the runtime filter at
1891/// `lib/src/protocol/mux/converter.rs::call` so config-load and runtime
1892/// agree on which header values may travel.
1893pub(crate) fn header_value_contains_forbidden_controls(bytes: &[u8]) -> bool {
1894    bytes
1895        .iter()
1896        .any(|&b| matches!(b, 0x00..=0x08 | 0x0A..=0x1F | 0x7F))
1897}
1898
1899#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1900#[serde(deny_unknown_fields, rename_all = "lowercase")]
1901pub enum ListenerProtocol {
1902    Http,
1903    Https,
1904    Tcp,
1905    Udp,
1906}
1907
1908#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1909#[serde(deny_unknown_fields, rename_all = "lowercase")]
1910pub enum FileClusterProtocolConfig {
1911    Http,
1912    Tcp,
1913}
1914
1915fn default_health_check_interval() -> u32 {
1916    10
1917}
1918fn default_health_check_timeout() -> u32 {
1919    5
1920}
1921fn default_health_check_threshold() -> u32 {
1922    3
1923}
1924
1925#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1926#[serde(deny_unknown_fields)]
1927pub struct FileHealthCheckConfig {
1928    pub uri: String,
1929    #[serde(default = "default_health_check_interval")]
1930    pub interval: u32,
1931    #[serde(default = "default_health_check_timeout")]
1932    pub timeout: u32,
1933    #[serde(default = "default_health_check_threshold")]
1934    pub healthy_threshold: u32,
1935    #[serde(default = "default_health_check_threshold")]
1936    pub unhealthy_threshold: u32,
1937    #[serde(default)]
1938    pub expected_status: u32,
1939}
1940
1941impl FileHealthCheckConfig {
1942    pub fn to_proto(&self) -> HealthCheckConfig {
1943        let proto = HealthCheckConfig {
1944            uri: self.uri.to_owned(),
1945            interval: self.interval,
1946            timeout: self.timeout,
1947            healthy_threshold: self.healthy_threshold,
1948            unhealthy_threshold: self.unhealthy_threshold,
1949            expected_status: self.expected_status,
1950        };
1951        // POST: the proto mirrors the file config exactly — the URI and all
1952        // timing knobs are carried through verbatim (no clamping or defaulting
1953        // happens at this layer; defaults are applied by serde at parse time).
1954        debug_assert_eq!(proto.uri, self.uri, "proto URI must mirror the file config");
1955        debug_assert!(
1956            proto.interval == self.interval
1957                && proto.timeout == self.timeout
1958                && proto.healthy_threshold == self.healthy_threshold
1959                && proto.unhealthy_threshold == self.unhealthy_threshold,
1960            "proto timing knobs must mirror the file config"
1961        );
1962        proto
1963    }
1964}
1965
1966/// Validate a [`HealthCheckConfig`] for the rules every layer relies on:
1967/// strict positive thresholds and a URI that cannot smuggle a second
1968/// HTTP message on the wire (RFC 9110 §5.1 — request-target). Used by
1969/// the CLI request builder and the worker `SetHealthCheck` handler so
1970/// off-channel inputs (TOML reload, third-party clients) are
1971/// constrained the same way as `sozu cluster health-check set`.
1972///
1973/// The function is intentionally [`Result<(), &'static str>`] rather
1974/// than carrying a structured error: the diagnostics only flow into
1975/// CLI output / worker error responses where the message is the value.
1976pub fn validate_health_check_config(cfg: &HealthCheckConfig) -> Result<(), &'static str> {
1977    if cfg.interval == 0 {
1978        return Err("health check interval must be > 0");
1979    }
1980    if cfg.timeout == 0 {
1981        return Err("health check timeout must be > 0");
1982    }
1983    if cfg.healthy_threshold == 0 {
1984        return Err("health check healthy_threshold must be > 0");
1985    }
1986    if cfg.unhealthy_threshold == 0 {
1987        return Err("health check unhealthy_threshold must be > 0");
1988    }
1989    if !cfg.uri.starts_with('/') {
1990        return Err("health check URI must start with '/'");
1991    }
1992    if cfg
1993        .uri
1994        .bytes()
1995        .any(|b| b == b'\r' || b == b'\n' || b == 0 || (b < 0x20 && b != b'\t'))
1996    {
1997        return Err("health check URI must not contain CR, LF, NUL, or other C0 control bytes");
1998    }
1999    // POST: a validated config has strictly-positive timing knobs (a zero
2000    // interval/timeout/threshold would make the health-check loop spin or
2001    // never converge) and a request-target the worker can splice into an HTTP
2002    // probe without smuggling a second message. Every Ok return is a witness
2003    // for all of these.
2004    debug_assert!(
2005        cfg.interval > 0
2006            && cfg.timeout > 0
2007            && cfg.healthy_threshold > 0
2008            && cfg.unhealthy_threshold > 0,
2009        "validated health-check thresholds must all be strictly positive"
2010    );
2011    debug_assert!(
2012        cfg.uri.starts_with('/'),
2013        "validated health-check URI must be an absolute path"
2014    );
2015    Ok(())
2016}
2017
2018#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2019#[serde(deny_unknown_fields)]
2020pub struct FileClusterConfig {
2021    pub frontends: Vec<FileClusterFrontendConfig>,
2022    pub backends: Vec<BackendConfig>,
2023    pub protocol: FileClusterProtocolConfig,
2024    pub sticky_session: Option<bool>,
2025    pub https_redirect: Option<bool>,
2026    #[serde(default)]
2027    pub send_proxy: Option<bool>,
2028    #[serde(default)]
2029    pub load_balancing: LoadBalancingAlgorithms,
2030    pub answer_503: Option<String>,
2031    #[serde(default)]
2032    pub load_metric: Option<LoadMetric>,
2033    /// Backend-capability hint: `true` when the backend speaks HTTP/2 (h2c or h2+TLS once #1218 lands).
2034    /// Does NOT gate H2 at the frontend — frontend H2 is ALPN-negotiated independently (see `alpn_protocols`).
2035    pub http2: Option<bool>,
2036    /// Per-cluster HTTP answer template overrides keyed by HTTP status
2037    /// code (e.g. `"503"`). Each value is either a filesystem path or an
2038    /// `inline:<body>` literal — see [`resolve_answer_source`]. Loaded
2039    /// into [`Cluster::answers`] at build time via [`load_answers`].
2040    ///
2041    /// Layering: an entry here overrides the listener-level
2042    /// `[listeners.<id>.answers]` default for the matching status on
2043    /// requests routed to this cluster. The listener-level map is the
2044    /// global default; the cluster-level map is the per-cluster
2045    /// override.
2046    pub answers: Option<BTreeMap<String, String>>,
2047    /// Optional explicit port to use when building the `Location` header
2048    /// for an `https_redirect`. When unset, the listener's effective HTTPS
2049    /// port is used. Lets operators front a non-standard HTTPS port (e.g.
2050    /// 8443) on the redirect target while keeping `https_redirect = true`.
2051    pub https_redirect_port: Option<u32>,
2052    /// Authorized credentials for HTTP basic authentication, formatted as
2053    /// `username:hex(sha256(password))` (lower-case hex). Empty list
2054    /// disables auth even when a frontend sets `required_auth = true` —
2055    /// such requests are rejected with 401.
2056    pub authorized_hashes: Option<Vec<String>>,
2057    /// Realm string emitted in `WWW-Authenticate: Basic realm="…"` when
2058    /// an unauthenticated request is rejected. Treated as an opaque
2059    /// value (no template substitution).
2060    pub www_authenticate: Option<String>,
2061    /// Override the global per-(cluster, source-IP) connection limit for
2062    /// this cluster. `None` (field absent) inherits the global default
2063    /// `max_connections_per_ip`. `Some(0)` is explicit "unlimited for
2064    /// this cluster". `Some(n > 0)` overrides with the cluster-specific
2065    /// limit. The source IP is taken from the parsed proxy-protocol
2066    /// header when present, else `peer_addr`.
2067    pub max_connections_per_ip: Option<u64>,
2068    /// Override the global `Retry-After` header value (seconds) emitted
2069    /// on HTTP 429 responses for this cluster. `None` inherits the global
2070    /// default. `Some(0)` omits the header. TCP clusters carry this
2071    /// field for shape uniformity but never emit the header (no HTTP
2072    /// envelope).
2073    pub retry_after: Option<u32>,
2074    /// Optional HTTP health-check configuration. The probe wire format
2075    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
2076    /// (h2c) when true.
2077    #[serde(default)]
2078    pub health_check: Option<FileHealthCheckConfig>,
2079    /// Optional UDP-specific cluster configuration, parsed from a
2080    /// `[clusters.<id>.udp]` block. Additive: clusters without a `udp`
2081    /// block produce `udp: None` on the resulting [`Cluster`].
2082    #[serde(default)]
2083    pub udp: Option<FileUdpClusterConfig>,
2084}
2085
2086/// UDP backend health-check configuration, parsed from
2087/// `[clusters.<id>.udp.health]`.
2088#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2089#[serde(deny_unknown_fields)]
2090pub struct FileUdpHealthConfig {
2091    /// probe mode, parsed in SCREAMING_SNAKE_CASE: `"HEALTH_OFF"`,
2092    /// `"TCP_PROBE"`, or `"UDP_PROBE"`. Defaults to `TCP_PROBE` when a
2093    /// `udp.health` block is present.
2094    pub mode: Option<UdpHealthMode>,
2095    pub tcp_port: Option<u32>,
2096    pub rise: Option<u32>,
2097    pub fall: Option<u32>,
2098    pub fail_open: Option<bool>,
2099    /// hex-free literal payload sent for a UDP probe.
2100    pub udp_probe_payload: Option<String>,
2101    pub probe_interval_seconds: Option<u32>,
2102    pub probe_timeout_seconds: Option<u32>,
2103}
2104
2105impl FileUdpHealthConfig {
2106    pub fn to_proto(&self) -> UdpHealthConfig {
2107        UdpHealthConfig {
2108            mode: self.mode.map(|m| m as i32),
2109            tcp_port: self.tcp_port,
2110            rise: self.rise,
2111            fall: self.fall,
2112            fail_open: self.fail_open,
2113            udp_probe_payload: self
2114                .udp_probe_payload
2115                .as_ref()
2116                .map(|p| p.as_bytes().to_owned()),
2117            probe_interval_seconds: self.probe_interval_seconds,
2118            probe_timeout_seconds: self.probe_timeout_seconds,
2119        }
2120    }
2121}
2122
2123/// UDP-specific cluster knobs, parsed from a `[clusters.<id>.udp]` block.
2124#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2125#[serde(deny_unknown_fields)]
2126pub struct FileUdpClusterConfig {
2127    /// flow affinity key, parsed in SCREAMING_SNAKE_CASE: `"SOURCE_IP"`
2128    /// (default) or `"SOURCE_IP_PORT"`.
2129    pub affinity_key: Option<UdpAffinityKey>,
2130    /// expected replies per flow; 0 = unlimited.
2131    pub responses: Option<u32>,
2132    /// max client datagrams per flow; 0 = unlimited.
2133    pub requests: Option<u32>,
2134    /// send a PROXY protocol v2 header to the backend.
2135    pub send_proxy_protocol: Option<bool>,
2136    /// prepend PPv2 to every datagram; false = first-datagram only.
2137    pub proxy_protocol_every_datagram: Option<bool>,
2138    /// optional backend health-check configuration.
2139    pub health: Option<FileUdpHealthConfig>,
2140}
2141
2142impl FileUdpClusterConfig {
2143    pub fn to_proto(&self) -> UdpClusterConfig {
2144        UdpClusterConfig {
2145            affinity_key: self.affinity_key.map(|k| k as i32),
2146            responses: self.responses,
2147            requests: self.requests,
2148            send_proxy_protocol: self.send_proxy_protocol,
2149            proxy_protocol_every_datagram: self.proxy_protocol_every_datagram,
2150            health: self.health.as_ref().map(|h| h.to_proto()),
2151        }
2152    }
2153}
2154
2155#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2156#[serde(deny_unknown_fields)]
2157pub struct BackendConfig {
2158    pub address: SocketAddr,
2159    pub weight: Option<u8>,
2160    pub sticky_id: Option<String>,
2161    pub backup: Option<bool>,
2162    pub backend_id: Option<String>,
2163}
2164
2165impl FileClusterConfig {
2166    pub fn to_cluster_config(
2167        self,
2168        cluster_id: &str,
2169        expect_proxy: &HashSet<SocketAddr>,
2170    ) -> Result<ClusterConfig, ConfigError> {
2171        // PRE: every frontend that converts cleanly must survive into the built
2172        // cluster — no frontend is silently dropped during conversion.
2173        let requested_frontend_count = self.frontends.len();
2174        match self.protocol {
2175            FileClusterProtocolConfig::Tcp => {
2176                let mut has_expect_proxy = None;
2177                let mut frontends = Vec::new();
2178                for f in self.frontends {
2179                    if expect_proxy.contains(&f.address) {
2180                        match has_expect_proxy {
2181                            Some(true) => {}
2182                            Some(false) => {
2183                                return Err(ConfigError::Incompatible {
2184                                    object: ObjectKind::Cluster,
2185                                    id: cluster_id.to_owned(),
2186                                    kind: IncompatibilityKind::ProxyProtocol,
2187                                });
2188                            }
2189                            None => has_expect_proxy = Some(true),
2190                        }
2191                    } else {
2192                        match has_expect_proxy {
2193                            Some(false) => {}
2194                            Some(true) => {
2195                                return Err(ConfigError::Incompatible {
2196                                    object: ObjectKind::Cluster,
2197                                    id: cluster_id.to_owned(),
2198                                    kind: IncompatibilityKind::ProxyProtocol,
2199                                });
2200                            }
2201                            None => has_expect_proxy = Some(false),
2202                        }
2203                    }
2204                    let tcp_frontend = f.to_tcp_front()?;
2205                    frontends.push(tcp_frontend);
2206                }
2207
2208                let send_proxy = self.send_proxy.unwrap_or(false);
2209                let expect_proxy = has_expect_proxy.unwrap_or(false);
2210                let proxy_protocol = match (send_proxy, expect_proxy) {
2211                    (true, true) => Some(ProxyProtocolConfig::RelayHeader),
2212                    (true, false) => Some(ProxyProtocolConfig::SendHeader),
2213                    (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
2214                    _ => None,
2215                };
2216
2217                let answers = match self.answers.as_ref() {
2218                    Some(map) => load_answers(map)?,
2219                    None => BTreeMap::new(),
2220                };
2221
2222                let udp = self.udp.as_ref().map(|u| u.to_proto());
2223                // POST: every requested frontend converted (none dropped), and
2224                // the resolved proxy-protocol mode is the documented function of
2225                // the (send, expect) pair — expect-only must never resolve to a
2226                // send-header mode and vice versa, which would corrupt the wire
2227                // framing.
2228                debug_assert_eq!(
2229                    frontends.len(),
2230                    requested_frontend_count,
2231                    "every TCP frontend must survive conversion"
2232                );
2233                debug_assert_eq!(
2234                    proxy_protocol,
2235                    match (send_proxy, expect_proxy) {
2236                        (true, true) => Some(ProxyProtocolConfig::RelayHeader),
2237                        (true, false) => Some(ProxyProtocolConfig::SendHeader),
2238                        (false, true) => Some(ProxyProtocolConfig::ExpectHeader),
2239                        (false, false) => None,
2240                    },
2241                    "proxy_protocol must be the (send, expect) function"
2242                );
2243
2244                Ok(ClusterConfig::Tcp(TcpClusterConfig {
2245                    cluster_id: cluster_id.to_string(),
2246                    frontends,
2247                    backends: self.backends,
2248                    proxy_protocol,
2249                    load_balancing: self.load_balancing,
2250                    load_metric: self.load_metric,
2251                    answers,
2252                    https_redirect_port: self.https_redirect_port,
2253                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
2254                    www_authenticate: self.www_authenticate,
2255                    max_connections_per_ip: self.max_connections_per_ip,
2256                    retry_after: self.retry_after,
2257                    health_check: self.health_check.as_ref().map(|hc| hc.to_proto()),
2258                    udp,
2259                }))
2260            }
2261            FileClusterProtocolConfig::Http => {
2262                let mut frontends = Vec::new();
2263                for frontend in self.frontends {
2264                    let http_frontend = frontend.to_http_front(cluster_id)?;
2265                    frontends.push(http_frontend);
2266                }
2267
2268                let answer_503 = self.answer_503.as_ref().and_then(|path| {
2269                    Config::load_file(path)
2270                        .map_err(|e| {
2271                            error!("cannot load 503 error page at path '{}': {:?}", path, e);
2272                            e
2273                        })
2274                        .ok()
2275                });
2276
2277                let answers = match self.answers.as_ref() {
2278                    Some(map) => load_answers(map)?,
2279                    None => BTreeMap::new(),
2280                };
2281
2282                let udp = self.udp.as_ref().map(|u| u.to_proto());
2283                // POST: every requested HTTP frontend converted — none dropped
2284                // (a dropped frontend would silently stop routing a hostname).
2285                debug_assert_eq!(
2286                    frontends.len(),
2287                    requested_frontend_count,
2288                    "every HTTP frontend must survive conversion"
2289                );
2290
2291                Ok(ClusterConfig::Http(HttpClusterConfig {
2292                    cluster_id: cluster_id.to_string(),
2293                    frontends,
2294                    backends: self.backends,
2295                    sticky_session: self.sticky_session.unwrap_or(false),
2296                    https_redirect: self.https_redirect.unwrap_or(false),
2297                    load_balancing: self.load_balancing,
2298                    load_metric: self.load_metric,
2299                    answer_503,
2300                    http2: self.http2,
2301                    answers,
2302                    https_redirect_port: self.https_redirect_port,
2303                    authorized_hashes: self.authorized_hashes.unwrap_or_default(),
2304                    www_authenticate: self.www_authenticate,
2305                    max_connections_per_ip: self.max_connections_per_ip,
2306                    retry_after: self.retry_after,
2307                    health_check: self.health_check.as_ref().map(|hc| hc.to_proto()),
2308                    udp,
2309                }))
2310            }
2311        }
2312    }
2313}
2314
2315#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2316#[serde(deny_unknown_fields)]
2317pub struct HttpFrontendConfig {
2318    pub address: SocketAddr,
2319    pub hostname: String,
2320    pub path: PathRule,
2321    pub method: Option<String>,
2322    pub certificate: Option<String>,
2323    pub key: Option<String>,
2324    pub certificate_chain: Option<Vec<String>>,
2325    #[serde(default)]
2326    pub tls_versions: Vec<TlsVersion>,
2327    #[serde(default)]
2328    pub position: RulePosition,
2329    pub tags: Option<BTreeMap<String, String>>,
2330    /// Resolved redirect policy. `None` keeps the proto-default `FORWARD`.
2331    #[serde(default)]
2332    pub redirect: Option<RedirectPolicy>,
2333    /// Resolved redirect scheme. `None` keeps the proto-default `USE_SAME`.
2334    #[serde(default)]
2335    pub redirect_scheme: Option<RedirectScheme>,
2336    #[serde(default)]
2337    pub redirect_template: Option<String>,
2338    #[serde(default)]
2339    pub rewrite_host: Option<String>,
2340    #[serde(default)]
2341    pub rewrite_path: Option<String>,
2342    #[serde(default)]
2343    pub rewrite_port: Option<u32>,
2344    #[serde(default)]
2345    pub required_auth: Option<bool>,
2346    /// Header mutations applied to requests and/or responses passing through
2347    /// this frontend. Empty by default.
2348    #[serde(default)]
2349    pub headers: Vec<Header>,
2350    /// Resolved per-frontend HSTS (RFC 6797) policy. `None` means inherit
2351    /// the listener default at frontend-add time in the worker.
2352    #[serde(default)]
2353    pub hsts: Option<HstsConfig>,
2354}
2355
2356impl HttpFrontendConfig {
2357    pub fn generate_requests(&self, cluster_id: &str) -> Vec<Request> {
2358        let mut v = Vec::new();
2359
2360        let tags = self.tags.clone().unwrap_or_default();
2361
2362        if self.key.is_some() && self.certificate.is_some() {
2363            v.push(
2364                RequestType::AddCertificate(AddCertificate {
2365                    address: self.address.into(),
2366                    certificate: CertificateAndKey {
2367                        key: self.key.clone().unwrap(),
2368                        certificate: self.certificate.clone().unwrap(),
2369                        certificate_chain: self.certificate_chain.clone().unwrap_or_default(),
2370                        versions: self.tls_versions.iter().map(|v| *v as i32).collect(),
2371                        // This field is used to override the certificate subject and san, we should not set it when
2372                        // loading the configuration, as we may provide a wildcard certificate for a specific domain.
2373                        // As a result, we will reject legit traffic for others domains as the certificate resolver will
2374                        // not load twice the same certificate and then do not register the certificate for others domains.
2375                        names: vec![],
2376                    },
2377                    expired_at: None,
2378                })
2379                .into(),
2380            );
2381
2382            v.push(
2383                RequestType::AddHttpsFrontend(RequestHttpFrontend {
2384                    cluster_id: Some(cluster_id.to_string()),
2385                    address: self.address.into(),
2386                    hostname: self.hostname.clone(),
2387                    path: self.path.clone(),
2388                    method: self.method.clone(),
2389                    position: self.position.into(),
2390                    tags,
2391                    redirect: self.redirect.map(|r| r as i32),
2392                    required_auth: self.required_auth,
2393                    redirect_scheme: self.redirect_scheme.map(|s| s as i32),
2394                    redirect_template: self.redirect_template.clone(),
2395                    rewrite_host: self.rewrite_host.clone(),
2396                    rewrite_path: self.rewrite_path.clone(),
2397                    rewrite_port: self.rewrite_port,
2398                    headers: self.headers.clone(),
2399                    hsts: self.hsts,
2400                })
2401                .into(),
2402            );
2403        } else {
2404            //create the front both for HTTP and HTTPS if possible
2405            v.push(
2406                RequestType::AddHttpFrontend(RequestHttpFrontend {
2407                    cluster_id: Some(cluster_id.to_string()),
2408                    address: self.address.into(),
2409                    hostname: self.hostname.clone(),
2410                    path: self.path.clone(),
2411                    method: self.method.clone(),
2412                    position: self.position.into(),
2413                    tags,
2414                    redirect: self.redirect.map(|r| r as i32),
2415                    required_auth: self.required_auth,
2416                    redirect_scheme: self.redirect_scheme.map(|s| s as i32),
2417                    redirect_template: self.redirect_template.clone(),
2418                    rewrite_host: self.rewrite_host.clone(),
2419                    rewrite_path: self.rewrite_path.clone(),
2420                    rewrite_port: self.rewrite_port,
2421                    headers: self.headers.clone(),
2422                    hsts: self.hsts,
2423                })
2424                .into(),
2425            );
2426        }
2427
2428        v
2429    }
2430}
2431
2432#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2433#[serde(deny_unknown_fields)]
2434pub struct HttpClusterConfig {
2435    pub cluster_id: String,
2436    pub frontends: Vec<HttpFrontendConfig>,
2437    pub backends: Vec<BackendConfig>,
2438    pub sticky_session: bool,
2439    pub https_redirect: bool,
2440    pub load_balancing: LoadBalancingAlgorithms,
2441    pub load_metric: Option<LoadMetric>,
2442    pub answer_503: Option<String>,
2443    pub http2: Option<bool>,
2444    /// Per-status template body map (already loaded from disk). Maps to
2445    /// the proto [`Cluster::answers`] field.
2446    #[serde(default)]
2447    pub answers: BTreeMap<String, String>,
2448    #[serde(default)]
2449    pub https_redirect_port: Option<u32>,
2450    #[serde(default)]
2451    pub authorized_hashes: Vec<String>,
2452    #[serde(default)]
2453    pub www_authenticate: Option<String>,
2454    /// Per-cluster override of the global `max_connections_per_ip`. See
2455    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
2456    #[serde(default)]
2457    pub max_connections_per_ip: Option<u64>,
2458    /// Per-cluster override of the global `retry_after` HTTP-429 header
2459    /// value (seconds). See [`FileClusterConfig::retry_after`].
2460    #[serde(default)]
2461    pub retry_after: Option<u32>,
2462    /// Optional HTTP health-check configuration. The probe wire format
2463    /// follows `cluster.http2`: HTTP/1.1 when false, HTTP/2 prior-knowledge
2464    /// (h2c) when true.
2465    #[serde(default)]
2466    pub health_check: Option<HealthCheckConfig>,
2467    /// Optional UDP-specific cluster configuration. Always `None` for HTTP
2468    /// clusters; carried for shape uniformity with the proto [`Cluster`].
2469    #[serde(default)]
2470    pub udp: Option<UdpClusterConfig>,
2471}
2472
2473impl HttpClusterConfig {
2474    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
2475        let mut v: Vec<Request> = vec![
2476            RequestType::AddCluster(Cluster {
2477                cluster_id: self.cluster_id.clone(),
2478                sticky_session: self.sticky_session,
2479                https_redirect: self.https_redirect,
2480                proxy_protocol: None,
2481                load_balancing: self.load_balancing as i32,
2482                answer_503: self.answer_503.clone(),
2483                load_metric: self.load_metric.map(|s| s as i32),
2484                http2: self.http2,
2485                answers: self.answers.clone(),
2486                https_redirect_port: self.https_redirect_port,
2487                authorized_hashes: self.authorized_hashes.clone(),
2488                www_authenticate: self.www_authenticate.clone(),
2489                max_connections_per_ip: self.max_connections_per_ip,
2490                retry_after: self.retry_after,
2491                health_check: self.health_check.clone(),
2492                udp: self.udp.clone(),
2493            })
2494            .into(),
2495        ];
2496
2497        for frontend in &self.frontends {
2498            let mut orders = frontend.generate_requests(&self.cluster_id);
2499            v.append(&mut orders);
2500        }
2501
2502        for (backend_count, backend) in self.backends.iter().enumerate() {
2503            let load_balancing_parameters = Some(LoadBalancingParams {
2504                weight: backend.weight.unwrap_or(100) as i32,
2505            });
2506
2507            v.push(
2508                RequestType::AddBackend(AddBackend {
2509                    cluster_id: self.cluster_id.clone(),
2510                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
2511                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
2512                    }),
2513                    address: backend.address.into(),
2514                    load_balancing_parameters,
2515                    sticky_id: backend.sticky_id.clone(),
2516                    backup: backend.backup,
2517                })
2518                .into(),
2519            );
2520        }
2521
2522        // POST: the order stream begins with exactly one AddCluster and emits
2523        // exactly one AddBackend per configured backend — a missing AddCluster
2524        // would orphan every backend, and a miscounted backend set would
2525        // silently drop or duplicate a backend registration.
2526        debug_assert!(
2527            matches!(
2528                v.first().and_then(|r| r.request_type.as_ref()),
2529                Some(RequestType::AddCluster(_))
2530            ),
2531            "HTTP cluster orders must lead with an AddCluster"
2532        );
2533        debug_assert_eq!(
2534            v.iter()
2535                .filter(|r| matches!(r.request_type, Some(RequestType::AddBackend(_))))
2536                .count(),
2537            self.backends.len(),
2538            "one AddBackend order per configured backend"
2539        );
2540        Ok(v)
2541    }
2542}
2543
2544#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2545pub struct TcpFrontendConfig {
2546    pub address: SocketAddr,
2547    pub tags: Option<BTreeMap<String, String>>,
2548    /// `true` when this frontend's address resolves to a `protocol = "udp"`
2549    /// listener. Resolved at config-load in [`ConfigBuilder::populate_clusters`]
2550    /// from `known_addresses`; selects `AddUdpFrontend` over `AddTcpFrontend`
2551    /// in [`TcpClusterConfig::generate_requests`]. A UDP cluster is declared as
2552    /// a `protocol = "tcp"` cluster whose frontends point at UDP listeners and
2553    /// whose datagram knobs live under `[clusters.<id>.udp]`.
2554    #[serde(default)]
2555    pub udp: bool,
2556}
2557
2558#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2559pub struct TcpClusterConfig {
2560    pub cluster_id: String,
2561    pub frontends: Vec<TcpFrontendConfig>,
2562    pub backends: Vec<BackendConfig>,
2563    #[serde(default)]
2564    pub proxy_protocol: Option<ProxyProtocolConfig>,
2565    pub load_balancing: LoadBalancingAlgorithms,
2566    pub load_metric: Option<LoadMetric>,
2567    /// Per-status template body map (already loaded from disk). Even
2568    /// though TCP clusters do not emit HTTP responses, the field is
2569    /// carried for shape uniformity with [`HttpClusterConfig`].
2570    #[serde(default)]
2571    pub answers: BTreeMap<String, String>,
2572    #[serde(default)]
2573    pub https_redirect_port: Option<u32>,
2574    #[serde(default)]
2575    pub authorized_hashes: Vec<String>,
2576    #[serde(default)]
2577    pub www_authenticate: Option<String>,
2578    /// Per-cluster override of the global `max_connections_per_ip`. See
2579    /// [`FileClusterConfig::max_connections_per_ip`] for semantics.
2580    #[serde(default)]
2581    pub max_connections_per_ip: Option<u64>,
2582    /// Per-cluster override of the global `retry_after`. TCP listeners
2583    /// never emit `Retry-After`; the field is carried for shape
2584    /// uniformity with [`HttpClusterConfig`].
2585    #[serde(default)]
2586    pub retry_after: Option<u32>,
2587    /// Optional HTTP health-check configuration. TCP clusters carry this
2588    /// field for shape uniformity with [`HttpClusterConfig`]; probes are
2589    /// HTTP/1.1 only and TCP-only backends should leave this absent.
2590    #[serde(default)]
2591    pub health_check: Option<HealthCheckConfig>,
2592    /// Optional UDP-specific cluster configuration, parsed from a
2593    /// `[clusters.<id>.udp]` block on this cluster.
2594    #[serde(default)]
2595    pub udp: Option<UdpClusterConfig>,
2596}
2597
2598impl TcpClusterConfig {
2599    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
2600        let mut v: Vec<Request> = vec![
2601            RequestType::AddCluster(Cluster {
2602                cluster_id: self.cluster_id.clone(),
2603                sticky_session: false,
2604                https_redirect: false,
2605                proxy_protocol: self.proxy_protocol.map(|s| s as i32),
2606                load_balancing: self.load_balancing as i32,
2607                load_metric: self.load_metric.map(|s| s as i32),
2608                answer_503: None,
2609                http2: None,
2610                answers: self.answers.clone(),
2611                https_redirect_port: self.https_redirect_port,
2612                authorized_hashes: self.authorized_hashes.clone(),
2613                www_authenticate: self.www_authenticate.clone(),
2614                max_connections_per_ip: self.max_connections_per_ip,
2615                retry_after: self.retry_after,
2616                health_check: self.health_check.clone(),
2617                udp: self.udp.clone(),
2618            })
2619            .into(),
2620        ];
2621
2622        for frontend in &self.frontends {
2623            // A frontend whose address resolves to a `protocol = "udp"`
2624            // listener (flagged in `populate_clusters`) is added as a UDP
2625            // frontend; all others stay TCP. Mixed TCP/UDP frontends on the
2626            // same cluster are supported.
2627            if frontend.udp {
2628                v.push(
2629                    RequestType::AddUdpFrontend(RequestUdpFrontend {
2630                        cluster_id: self.cluster_id.clone(),
2631                        address: frontend.address.into(),
2632                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
2633                    })
2634                    .into(),
2635                );
2636            } else {
2637                v.push(
2638                    RequestType::AddTcpFrontend(RequestTcpFrontend {
2639                        cluster_id: self.cluster_id.clone(),
2640                        address: frontend.address.into(),
2641                        tags: frontend.tags.clone().unwrap_or(BTreeMap::new()),
2642                    })
2643                    .into(),
2644                );
2645            }
2646        }
2647
2648        for (backend_count, backend) in self.backends.iter().enumerate() {
2649            let load_balancing_parameters = Some(LoadBalancingParams {
2650                weight: backend.weight.unwrap_or(100) as i32,
2651            });
2652
2653            v.push(
2654                RequestType::AddBackend(AddBackend {
2655                    cluster_id: self.cluster_id.clone(),
2656                    backend_id: backend.backend_id.clone().unwrap_or_else(|| {
2657                        format!("{}-{}-{}", self.cluster_id, backend_count, backend.address)
2658                    }),
2659                    address: backend.address.into(),
2660                    load_balancing_parameters,
2661                    sticky_id: backend.sticky_id.clone(),
2662                    backup: backend.backup,
2663                })
2664                .into(),
2665            );
2666        }
2667
2668        // POST: the order stream leads with one AddCluster and emits exactly
2669        // one AddTcpFrontend per frontend and one AddBackend per backend — the
2670        // worker reconstructs the cluster topology solely from these counts.
2671        debug_assert!(
2672            matches!(
2673                v.first().and_then(|r| r.request_type.as_ref()),
2674                Some(RequestType::AddCluster(_))
2675            ),
2676            "TCP cluster orders must lead with an AddCluster"
2677        );
2678        debug_assert_eq!(
2679            v.iter()
2680                .filter(|r| matches!(
2681                    r.request_type,
2682                    Some(RequestType::AddTcpFrontend(_)) | Some(RequestType::AddUdpFrontend(_))
2683                ))
2684                .count(),
2685            self.frontends.len(),
2686            "one AddTcpFrontend or AddUdpFrontend order per configured frontend"
2687        );
2688        debug_assert_eq!(
2689            v.iter()
2690                .filter(|r| matches!(r.request_type, Some(RequestType::AddBackend(_))))
2691                .count(),
2692            self.backends.len(),
2693            "one AddBackend order per configured backend"
2694        );
2695        Ok(v)
2696    }
2697}
2698
2699#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2700pub enum ClusterConfig {
2701    Http(HttpClusterConfig),
2702    Tcp(TcpClusterConfig),
2703}
2704
2705impl ClusterConfig {
2706    pub fn generate_requests(&self) -> Result<Vec<Request>, ConfigError> {
2707        match *self {
2708            ClusterConfig::Http(ref http) => http.generate_requests(),
2709            ClusterConfig::Tcp(ref tcp) => tcp.generate_requests(),
2710        }
2711    }
2712}
2713
2714/// Parsed from the TOML config provided by the user.
2715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
2716pub struct FileConfig {
2717    pub command_socket: Option<String>,
2718    pub command_buffer_size: Option<u64>,
2719    pub max_command_buffer_size: Option<u64>,
2720    pub max_connections: Option<usize>,
2721    pub min_buffers: Option<u64>,
2722    pub max_buffers: Option<u64>,
2723    pub buffer_size: Option<u64>,
2724    /// Slab-entries-per-connection multiplier. `None` keeps the compile-time
2725    /// default of 4. Operator-visible escape hatch for fan-out topologies
2726    /// that exceed 4 backends per session — clamped to [2, 32] at load.
2727    #[serde(default)]
2728    pub slab_entries_per_connection: Option<u64>,
2729    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
2730    /// payload accepted by the worker's `mux::auth` module. Caps the
2731    /// per-failed-auth allocation so a hostile peer cannot force the worker
2732    /// to decode arbitrarily large tokens. RFC 7617 imposes no upper bound
2733    /// — defaults to 4096, which is well above the realistic
2734    /// `username:password` shape. Operators running hardened tenants can
2735    /// lower this to e.g. 256 or 512 to bound the allocation tighter.
2736    /// Values >= `buffer_size / 3` emit a warning at config-load time
2737    /// (the credential cap shouldn't dominate the per-frontend buffer).
2738    #[serde(default)]
2739    pub basic_auth_max_credential_bytes: Option<u64>,
2740    /// Default per-(cluster, source-IP) connection limit. `None` keeps
2741    /// `0` (unlimited). Each cluster may override via its own
2742    /// `max_connections_per_ip`. The source IP is taken from the parsed
2743    /// proxy-protocol header when present, else `peer_addr`. When the
2744    /// limit is reached, HTTP requests are answered with `429 Too Many
2745    /// Requests` (with optional `Retry-After`) and TCP sessions are
2746    /// closed gracefully without dialing the backend.
2747    #[serde(default)]
2748    pub max_connections_per_ip: Option<u64>,
2749    /// Default `Retry-After` header value (seconds) sent on HTTP 429
2750    /// responses. `Some(0)` or `None` keeping the default `0` omits the
2751    /// header (rendering `Retry-After: 0` invites an immediate retry that
2752    /// defeats the limit). Per-cluster overrides apply for HTTP listeners
2753    /// only. TCP listeners ignore this value (no HTTP envelope).
2754    #[serde(default)]
2755    pub retry_after: Option<u32>,
2756    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
2757    /// zero-copy direction (Linux only, `splice` feature). `None` keeps
2758    /// the kernel default (64 KiB). Applied via `fcntl(F_SETPIPE_SZ)`;
2759    /// the kernel rounds up to a page boundary and clamps at
2760    /// `/proc/sys/fs/pipe-max-size` (default 1 MiB unprivileged). The
2761    /// realised capacity is read back via `fcntl(F_GETPIPE_SZ)` and
2762    /// drives the per-call `len` for `splice_in`. Ignored on non-Linux
2763    /// targets and on builds without the `splice` feature.
2764    #[serde(default)]
2765    pub splice_pipe_capacity_bytes: Option<u64>,
2766    /// Optional UID allowlist for command-socket requests. `None` (default)
2767    /// preserves historical behaviour: any same-UID local process can
2768    /// invoke any verb. When set, requests whose `SO_PEERCRED` UID is not
2769    /// in the list are rejected. Use to restrict mutating verbs to a
2770    /// specific operator UID even when other same-UID daemons coexist
2771    /// (CI runners, monitoring).
2772    #[serde(default)]
2773    pub command_allowed_uids: Option<Vec<u32>>,
2774    pub saved_state: Option<String>,
2775    #[serde(default)]
2776    pub automatic_state_save: Option<bool>,
2777    pub log_level: Option<String>,
2778    pub log_target: Option<String>,
2779    #[serde(default)]
2780    pub log_colored: bool,
2781    /// Dedicated file path for the control-plane audit log. When set, every
2782    /// emitted `[AUDIT]` / `Command(...)` line is also appended to this file
2783    /// opened `O_APPEND | O_CREAT` with mode `0o640` (owner read+write,
2784    /// group read, world nothing) so operators can separate the audit trail
2785    /// from the main log stream and protect it with group-scoped ACLs /
2786    /// logrotate. Independent of the standard `log_target`. `None` keeps
2787    /// audit lines routed only through the standard logger.
2788    #[serde(default)]
2789    pub audit_logs_target: Option<String>,
2790    /// Dedicated file path for a JSON-encoded mirror of the audit log.
2791    /// One JSON object per line so SIEM pipelines (Wazuh, Elastic, Loki)
2792    /// ingest without bespoke parsers. Same `O_APPEND | O_CREAT | 0o640`
2793    /// as `audit_logs_target`. `None` disables the JSON mirror.
2794    #[serde(default)]
2795    pub audit_logs_json_target: Option<String>,
2796    #[serde(default)]
2797    pub access_logs_target: Option<String>,
2798    #[serde(default)]
2799    pub access_logs_format: Option<AccessLogFormat>,
2800    #[serde(default)]
2801    pub access_logs_colored: Option<bool>,
2802    pub worker_count: Option<u16>,
2803    pub worker_automatic_restart: Option<bool>,
2804    pub metrics: Option<MetricsConfig>,
2805    pub disable_cluster_metrics: Option<bool>,
2806    pub listeners: Option<Vec<ListenerBuilder>>,
2807    pub clusters: Option<HashMap<String, FileClusterConfig>>,
2808    pub handle_process_affinity: Option<bool>,
2809    pub ctl_command_timeout: Option<u64>,
2810    pub pid_file_path: Option<String>,
2811    pub activate_listeners: Option<bool>,
2812    #[serde(default)]
2813    pub front_timeout: Option<u32>,
2814    #[serde(default)]
2815    pub back_timeout: Option<u32>,
2816    #[serde(default)]
2817    pub connect_timeout: Option<u32>,
2818    #[serde(default)]
2819    pub zombie_check_interval: Option<u32>,
2820    #[serde(default)]
2821    pub accept_queue_timeout: Option<u32>,
2822    #[serde(default)]
2823    pub evict_on_queue_full: Option<bool>,
2824    #[serde(default)]
2825    pub request_timeout: Option<u32>,
2826    #[serde(default)]
2827    pub worker_timeout: Option<u32>,
2828}
2829
2830impl FileConfig {
2831    pub fn load_from_path(path: &str) -> Result<FileConfig, ConfigError> {
2832        let data = Config::load_file(path)?;
2833
2834        let config: FileConfig = match toml::from_str(&data) {
2835            Ok(config) => config,
2836            Err(e) => {
2837                display_toml_error(&data, &e);
2838                return Err(ConfigError::DeserializeToml(e.to_string()));
2839            }
2840        };
2841
2842        let mut reserved_address: HashSet<SocketAddr> = HashSet::new();
2843
2844        if let Some(listeners) = config.listeners.as_ref() {
2845            for listener in listeners.iter() {
2846                if reserved_address.contains(&listener.address) {
2847                    return Err(ConfigError::ListenerAddressAlreadyInUse(listener.address));
2848                }
2849                reserved_address.insert(listener.address);
2850            }
2851        }
2852
2853        //FIXME: verify how clusters and listeners share addresses
2854        /*
2855        if let Some(ref clusters) = config.clusters {
2856          for (key, cluster) in clusters.iter() {
2857            if let (Some(address), Some(port)) = (cluster.ip_address.clone(), cluster.port) {
2858              let addr = (address, port);
2859              if reserved_address.contains(&addr) {
2860                println!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
2861                  key, addr.0, addr.1);
2862                return Err(Error::new(
2863                  ErrorKind::InvalidData,
2864                  format!("TCP cluster '{}' listening address ( {}:{} ) is already used in the configuration",
2865                    key, addr.0, addr.1)));
2866              } else {
2867                reserved_address.insert(addr.clone());
2868              }
2869            }
2870          }
2871        }
2872        */
2873
2874        Ok(config)
2875    }
2876}
2877
2878/// A builder that converts [FileConfig] to [Config]
2879pub struct ConfigBuilder {
2880    file: FileConfig,
2881    known_addresses: HashMap<SocketAddr, ListenerProtocol>,
2882    expect_proxy_addresses: HashSet<SocketAddr>,
2883    built: Config,
2884}
2885
2886impl ConfigBuilder {
2887    /// starts building a [Config] with values from a [FileConfig], or defaults.
2888    ///
2889    /// please provide a config path, usefull for rebuilding the config later.
2890    pub fn new<S>(file_config: FileConfig, config_path: S) -> Self
2891    where
2892        S: ToString,
2893    {
2894        let built = Config {
2895            accept_queue_timeout: file_config
2896                .accept_queue_timeout
2897                .unwrap_or(DEFAULT_ACCEPT_QUEUE_TIMEOUT),
2898            evict_on_queue_full: file_config
2899                .evict_on_queue_full
2900                .unwrap_or(DEFAULT_EVICT_ON_QUEUE_FULL),
2901            activate_listeners: file_config.activate_listeners.unwrap_or(true),
2902            automatic_state_save: file_config
2903                .automatic_state_save
2904                .unwrap_or(DEFAULT_AUTOMATIC_STATE_SAVE),
2905            back_timeout: file_config.back_timeout.unwrap_or(DEFAULT_BACK_TIMEOUT),
2906            buffer_size: file_config.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE),
2907            command_buffer_size: file_config
2908                .command_buffer_size
2909                .unwrap_or(DEFAULT_COMMAND_BUFFER_SIZE),
2910            config_path: config_path.to_string(),
2911            connect_timeout: file_config
2912                .connect_timeout
2913                .unwrap_or(DEFAULT_CONNECT_TIMEOUT),
2914            ctl_command_timeout: file_config.ctl_command_timeout.unwrap_or(1_000),
2915            front_timeout: file_config.front_timeout.unwrap_or(DEFAULT_FRONT_TIMEOUT),
2916            handle_process_affinity: file_config.handle_process_affinity.unwrap_or(false),
2917            access_logs_target: file_config.access_logs_target.clone(),
2918            audit_logs_target: file_config.audit_logs_target.clone(),
2919            audit_logs_json_target: file_config.audit_logs_json_target.clone(),
2920            access_logs_format: file_config.access_logs_format.clone(),
2921            access_logs_colored: file_config.access_logs_colored,
2922            log_level: file_config
2923                .log_level
2924                .clone()
2925                .unwrap_or_else(|| String::from("info")),
2926            log_target: file_config
2927                .log_target
2928                .clone()
2929                .unwrap_or_else(|| String::from("stdout")),
2930            log_colored: file_config.log_colored,
2931            max_buffers: file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
2932            max_command_buffer_size: file_config
2933                .max_command_buffer_size
2934                .unwrap_or(DEFAULT_MAX_COMMAND_BUFFER_SIZE),
2935            max_connections: file_config
2936                .max_connections
2937                .unwrap_or(DEFAULT_MAX_CONNECTIONS),
2938            metrics: file_config.metrics.clone(),
2939            disable_cluster_metrics: file_config
2940                .disable_cluster_metrics
2941                .unwrap_or(DEFAULT_DISABLE_CLUSTER_METRICS),
2942            min_buffers: std::cmp::min(
2943                file_config.min_buffers.unwrap_or(DEFAULT_MIN_BUFFERS),
2944                file_config.max_buffers.unwrap_or(DEFAULT_MAX_BUFFERS),
2945            ),
2946            pid_file_path: file_config.pid_file_path.clone(),
2947            request_timeout: file_config
2948                .request_timeout
2949                .unwrap_or(DEFAULT_REQUEST_TIMEOUT),
2950            saved_state: file_config.saved_state.clone(),
2951            worker_automatic_restart: file_config
2952                .worker_automatic_restart
2953                .unwrap_or(DEFAULT_WORKER_AUTOMATIC_RESTART),
2954            worker_count: file_config.worker_count.unwrap_or(DEFAULT_WORKER_COUNT),
2955            zombie_check_interval: file_config
2956                .zombie_check_interval
2957                .unwrap_or(DEFAULT_ZOMBIE_CHECK_INTERVAL),
2958            worker_timeout: file_config.worker_timeout.unwrap_or(DEFAULT_WORKER_TIMEOUT),
2959            slab_entries_per_connection: file_config.slab_entries_per_connection.map(|n| {
2960                n.clamp(
2961                    ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION,
2962                    ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION,
2963                )
2964            }),
2965            command_allowed_uids: file_config.command_allowed_uids.clone(),
2966            basic_auth_max_credential_bytes: file_config.basic_auth_max_credential_bytes,
2967            max_connections_per_ip: file_config
2968                .max_connections_per_ip
2969                .unwrap_or(DEFAULT_MAX_CONNECTIONS_PER_IP),
2970            retry_after: file_config.retry_after.unwrap_or(DEFAULT_RETRY_AFTER),
2971            splice_pipe_capacity_bytes: file_config.splice_pipe_capacity_bytes,
2972            ..Default::default()
2973        };
2974
2975        // POST: the buffer free-list floor is clamped to never exceed the
2976        // ceiling — the `std::cmp::min(min_buffers, max_buffers)` above is the
2977        // sole guarantor of this, so assert it held.
2978        debug_assert!(
2979            built.min_buffers <= built.max_buffers,
2980            "min_buffers must be clamped to <= max_buffers in the builder"
2981        );
2982        // POST: an explicit slab override, if present, was clamped into the
2983        // documented [MIN, MAX] window; an absent override stays None.
2984        debug_assert!(
2985            built.slab_entries_per_connection.is_none_or(|n| {
2986                (ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION
2987                    ..=ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION)
2988                    .contains(&n)
2989            }),
2990            "a set slab_entries_per_connection must be clamped into [MIN, MAX]"
2991        );
2992
2993        Self {
2994            file: file_config,
2995            known_addresses: HashMap::new(),
2996            expect_proxy_addresses: HashSet::new(),
2997            built,
2998        }
2999    }
3000
3001    fn push_tls_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
3002        let listener = listener.to_tls(Some(&self.built))?;
3003        self.built.https_listeners.push(listener);
3004        Ok(())
3005    }
3006
3007    fn push_http_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
3008        let listener = listener.to_http(Some(&self.built))?;
3009        self.built.http_listeners.push(listener);
3010        Ok(())
3011    }
3012
3013    fn push_tcp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
3014        let listener = listener.to_tcp(Some(&self.built))?;
3015        self.built.tcp_listeners.push(listener);
3016        Ok(())
3017    }
3018
3019    fn push_udp_listener(&mut self, mut listener: ListenerBuilder) -> Result<(), ConfigError> {
3020        let listener = listener.to_udp(Some(&self.built))?;
3021        self.built.udp_listeners.push(listener);
3022        Ok(())
3023    }
3024
3025    fn populate_listeners(&mut self, listeners: Vec<ListenerBuilder>) -> Result<(), ConfigError> {
3026        for listener in listeners.iter() {
3027            if self.known_addresses.contains_key(&listener.address) {
3028                return Err(ConfigError::ListenerAddressAlreadyInUse(listener.address));
3029            }
3030
3031            let protocol = listener
3032                .protocol
3033                .ok_or(ConfigError::Missing(MissingKind::Protocol))?;
3034
3035            self.known_addresses.insert(listener.address, protocol);
3036            if listener.expect_proxy == Some(true) {
3037                self.expect_proxy_addresses.insert(listener.address);
3038            }
3039
3040            if listener.public_address.is_some() && listener.expect_proxy == Some(true) {
3041                return Err(ConfigError::Incompatible {
3042                    object: ObjectKind::Listener,
3043                    id: listener.address.to_string(),
3044                    kind: IncompatibilityKind::PublicAddress,
3045                });
3046            }
3047
3048            match protocol {
3049                ListenerProtocol::Https => self.push_tls_listener(listener.clone())?,
3050                ListenerProtocol::Http => self.push_http_listener(listener.clone())?,
3051                ListenerProtocol::Tcp => self.push_tcp_listener(listener.clone())?,
3052                ListenerProtocol::Udp => self.push_udp_listener(listener.clone())?,
3053            }
3054        }
3055        Ok(())
3056    }
3057
3058    fn populate_clusters(
3059        &mut self,
3060        mut file_cluster_configs: HashMap<String, FileClusterConfig>,
3061    ) -> Result<(), ConfigError> {
3062        for (id, file_cluster_config) in file_cluster_configs.drain() {
3063            let mut cluster_config =
3064                file_cluster_config.to_cluster_config(id.as_str(), &self.expect_proxy_addresses)?;
3065
3066            match cluster_config {
3067                ClusterConfig::Http(ref mut http) => {
3068                    for frontend in http.frontends.iter_mut() {
3069                        match self.known_addresses.get(&frontend.address) {
3070                            Some(ListenerProtocol::Tcp) => {
3071                                return Err(ConfigError::WrongFrontendProtocol(
3072                                    ListenerProtocol::Tcp,
3073                                ));
3074                            }
3075                            Some(ListenerProtocol::Udp) => {
3076                                return Err(ConfigError::WrongFrontendProtocol(
3077                                    ListenerProtocol::Udp,
3078                                ));
3079                            }
3080                            Some(ListenerProtocol::Http) => {
3081                                if frontend.certificate.is_some() {
3082                                    return Err(ConfigError::WrongFrontendProtocol(
3083                                        ListenerProtocol::Http,
3084                                    ));
3085                                }
3086                            }
3087                            Some(ListenerProtocol::Https) => {
3088                                if frontend.certificate.is_none() {
3089                                    if let Some(https_listener) =
3090                                        self.built.https_listeners.iter().find(|listener| {
3091                                            listener.address == frontend.address.into()
3092                                                && listener.certificate.is_some()
3093                                        })
3094                                    {
3095                                        //println!("using listener certificate for {:}", frontend.address);
3096                                        frontend
3097                                            .certificate
3098                                            .clone_from(&https_listener.certificate);
3099                                        frontend.certificate_chain =
3100                                            Some(https_listener.certificate_chain.clone());
3101                                        frontend.key.clone_from(&https_listener.key);
3102                                    }
3103                                    if frontend.certificate.is_none() {
3104                                        debug!("known addresses: {:?}", self.known_addresses);
3105                                        debug!("frontend: {:?}", frontend);
3106                                        return Err(ConfigError::WrongFrontendProtocol(
3107                                            ListenerProtocol::Https,
3108                                        ));
3109                                    }
3110                                }
3111                            }
3112                            None => {
3113                                // create a default listener for that front
3114                                let file_listener_protocol = if frontend.certificate.is_some() {
3115                                    self.push_tls_listener(ListenerBuilder::new(
3116                                        frontend.address.into(),
3117                                        ListenerProtocol::Https,
3118                                    ))?;
3119
3120                                    ListenerProtocol::Https
3121                                } else {
3122                                    self.push_http_listener(ListenerBuilder::new(
3123                                        frontend.address.into(),
3124                                        ListenerProtocol::Http,
3125                                    ))?;
3126
3127                                    ListenerProtocol::Http
3128                                };
3129                                self.known_addresses
3130                                    .insert(frontend.address, file_listener_protocol);
3131                            }
3132                        }
3133                    }
3134                }
3135                ClusterConfig::Tcp(ref mut tcp) => {
3136                    //FIXME: verify that different TCP clusters do not request the same address
3137                    for frontend in tcp.frontends.iter_mut() {
3138                        match self.known_addresses.get(&frontend.address) {
3139                            Some(ListenerProtocol::Http) | Some(ListenerProtocol::Https) => {
3140                                return Err(ConfigError::WrongFrontendProtocol(
3141                                    ListenerProtocol::Http,
3142                                ));
3143                            }
3144                            Some(ListenerProtocol::Udp) => {
3145                                // A `protocol = "tcp"` cluster whose frontend
3146                                // points at a `protocol = "udp"` listener is a
3147                                // UDP cluster (datagram knobs under
3148                                // `[clusters.<id>.udp]`). Mark the frontend so
3149                                // `generate_requests` emits `AddUdpFrontend`.
3150                                frontend.udp = true;
3151                            }
3152                            Some(ListenerProtocol::Tcp) => {}
3153                            None => {
3154                                // create a default listener for that front
3155                                self.push_tcp_listener(ListenerBuilder::new(
3156                                    frontend.address.into(),
3157                                    ListenerProtocol::Tcp,
3158                                ))?;
3159                                self.known_addresses
3160                                    .insert(frontend.address, ListenerProtocol::Tcp);
3161                            }
3162                        }
3163                    }
3164                }
3165            }
3166
3167            self.built.clusters.insert(id, cluster_config);
3168        }
3169        Ok(())
3170    }
3171
3172    /// Builds a [`Config`], populated with listeners and clusters
3173    pub fn into_config(&mut self) -> Result<Config, ConfigError> {
3174        if let Some(listeners) = &self.file.listeners {
3175            self.populate_listeners(listeners.clone())?;
3176        }
3177
3178        if let Some(file_cluster_configs) = &self.file.clusters {
3179            self.populate_clusters(file_cluster_configs.clone())?;
3180        }
3181
3182        // RFC 9113 §6.5.2 + §4.1: the H2 mux must accept up to
3183        // SETTINGS_MAX_FRAME_SIZE (16 384) + 9-byte frame header in a single
3184        // kawa buffer. If any HTTPS listener advertises "h2" in its ALPN list
3185        // and the global buffer_size is below H2_MIN_BUFFER_SIZE, the mux
3186        // deadlocks on full-size DATA / HEADERS / CONTINUATION frames until
3187        // the session timeout fires. Reject at config load so the failure
3188        // mode surfaces at boot, not under traffic.
3189        // Long-form rationale: `lib/src/protocol/mux/LIFECYCLE.md`.
3190        let h2_listeners = self
3191            .built
3192            .https_listeners
3193            .iter()
3194            .filter(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
3195            .count();
3196        if h2_listeners > 0 && self.built.buffer_size < H2_MIN_BUFFER_SIZE {
3197            return Err(ConfigError::BufferSizeTooSmallForH2 {
3198                buffer_size: self.built.buffer_size,
3199                minimum: H2_MIN_BUFFER_SIZE,
3200                listeners: h2_listeners,
3201            });
3202        }
3203
3204        // Warn (no hard reject) when the configured Basic-auth credential
3205        // cap is large enough to dominate the per-frontend buffer. The
3206        // worker copies a decoded credential into a transient allocation
3207        // sized by this cap; values >= 33% of `buffer_size` mean a single
3208        // failed-auth attempt can hold a third of the buffer's worth of
3209        // bytes, which combined with in-flight request/response framing
3210        // pushes the buffer toward back-pressure under load. Log only —
3211        // operators with deliberate threat models may choose this
3212        // trade-off, but the surprise needs to be visible.
3213        if let Some(cap) = self.built.basic_auth_max_credential_bytes {
3214            let third = self.built.buffer_size / 3;
3215            if cap >= third {
3216                warn!(
3217                    "basic_auth_max_credential_bytes = {} is >= buffer_size / 3 ({}); \
3218                     a hostile peer can pin ~33% of the per-frontend buffer per failed auth \
3219                     attempt. Consider lowering basic_auth_max_credential_bytes (typical \
3220                     credentials are <100 bytes) or raising buffer_size.",
3221                    cap, third
3222                );
3223            }
3224        }
3225
3226        // The eviction batch is `(max_connections / 100).max(1)` — a 1% ratio
3227        // by design. Below 100 connections the floor of 1 means each cap
3228        // event evicts a larger share than 1% of capacity (e.g. 4% at
3229        // max_connections=25), which can surprise an operator who reads the
3230        // knob as "1% per round". Warn at config load so the discrepancy is
3231        // visible at boot, not under traffic.
3232        if self.built.evict_on_queue_full && self.built.max_connections < 100 {
3233            let pct = 100usize.div_ceil(self.built.max_connections);
3234            warn!(
3235                "evict_on_queue_full enabled with max_connections = {}; the eviction batch \
3236                 clamps to 1, equivalent to ~{}% of capacity per cap event (the knob is \
3237                 documented as 1%). Confirm this is intended.",
3238                self.built.max_connections, pct
3239            );
3240        }
3241
3242        let command_socket_path = self.file.command_socket.clone().unwrap_or({
3243            let mut path = env::current_dir().map_err(|e| ConfigError::Env(e.to_string()))?;
3244            path.push("sozu.sock");
3245            let verified_path = path
3246                .to_str()
3247                .ok_or(ConfigError::InvalidPath(path.clone()))?;
3248            verified_path.to_owned()
3249        });
3250
3251        if let (None, Some(true)) = (&self.file.saved_state, &self.file.automatic_state_save) {
3252            return Err(ConfigError::Missing(MissingKind::SavedState));
3253        }
3254
3255        let config = Config {
3256            command_socket: command_socket_path,
3257            ..self.built.clone()
3258        };
3259
3260        // POST: a successfully built config satisfies the buffer-pool
3261        // invariants every worker relies on.
3262        // 1. min_buffers <= max_buffers — guaranteed by the `std::cmp::min`
3263        //    clamp in `new`; a violation would let a worker size its free-list
3264        //    floor above its ceiling.
3265        debug_assert!(
3266            config.min_buffers <= config.max_buffers,
3267            "min_buffers must not exceed max_buffers"
3268        );
3269        // 2. If any HTTPS listener advertises h2 in its ALPN, the global
3270        //    buffer_size is at least the H2 minimum — otherwise the early
3271        //    return above would have produced a BufferSizeTooSmallForH2 error
3272        //    rather than this Ok. (Recomputed here so the assert is independent
3273        //    of the local `h2_listeners` binding above.)
3274        debug_assert!(
3275            !config
3276                .https_listeners
3277                .iter()
3278                .any(|l| l.alpn_protocols.iter().any(|p| p == "h2"))
3279                || config.buffer_size >= H2_MIN_BUFFER_SIZE,
3280            "an h2-advertising config must satisfy the H2 minimum buffer size"
3281        );
3282        Ok(config)
3283    }
3284}
3285
3286/// Sōzu configuration, populated with clusters and listeners.
3287///
3288/// This struct is used on startup to generate `WorkerRequest`s
3289#[derive(Clone, PartialEq, Eq, Serialize, Default, Deserialize)]
3290pub struct Config {
3291    pub config_path: String,
3292    pub command_socket: String,
3293    pub command_buffer_size: u64,
3294    pub max_command_buffer_size: u64,
3295    pub max_connections: usize,
3296    pub min_buffers: u64,
3297    pub max_buffers: u64,
3298    pub buffer_size: u64,
3299    pub saved_state: Option<String>,
3300    #[serde(default)]
3301    pub automatic_state_save: bool,
3302    pub log_level: String,
3303    pub log_target: String,
3304    pub log_colored: bool,
3305    /// Optional dedicated file path for the control-plane audit log. See
3306    /// `FileConfig::audit_logs_target` for rationale.
3307    #[serde(default)]
3308    pub audit_logs_target: Option<String>,
3309    /// Optional JSON mirror of the audit log; see
3310    /// `FileConfig::audit_logs_json_target`.
3311    #[serde(default)]
3312    pub audit_logs_json_target: Option<String>,
3313    #[serde(default)]
3314    pub access_logs_target: Option<String>,
3315    pub access_logs_format: Option<AccessLogFormat>,
3316    pub access_logs_colored: Option<bool>,
3317    pub worker_count: u16,
3318    pub worker_automatic_restart: bool,
3319    pub metrics: Option<MetricsConfig>,
3320    #[serde(default = "default_disable_cluster_metrics")]
3321    pub disable_cluster_metrics: bool,
3322    pub http_listeners: Vec<HttpListenerConfig>,
3323    pub https_listeners: Vec<HttpsListenerConfig>,
3324    pub tcp_listeners: Vec<TcpListenerConfig>,
3325    #[serde(default)]
3326    pub udp_listeners: Vec<UdpListenerConfig>,
3327    pub clusters: HashMap<String, ClusterConfig>,
3328    pub handle_process_affinity: bool,
3329    pub ctl_command_timeout: u64,
3330    pub pid_file_path: Option<String>,
3331    pub activate_listeners: bool,
3332    #[serde(default = "default_front_timeout")]
3333    pub front_timeout: u32,
3334    #[serde(default = "default_back_timeout")]
3335    pub back_timeout: u32,
3336    #[serde(default = "default_connect_timeout")]
3337    pub connect_timeout: u32,
3338    #[serde(default = "default_zombie_check_interval")]
3339    pub zombie_check_interval: u32,
3340    #[serde(default = "default_accept_queue_timeout")]
3341    pub accept_queue_timeout: u32,
3342    #[serde(default = "default_evict_on_queue_full")]
3343    pub evict_on_queue_full: bool,
3344    #[serde(default = "default_request_timeout")]
3345    pub request_timeout: u32,
3346    #[serde(default = "default_worker_timeout")]
3347    pub worker_timeout: u32,
3348    /// Slab-entries-per-connection multiplier exposed for operators with
3349    /// fan-out topologies that exceed the default 4 backends per session.
3350    /// `None` means the default (4) applies; set values are clamped to
3351    /// [`ServerConfig::MIN_SLAB_ENTRIES_PER_CONNECTION`,
3352    /// `ServerConfig::MAX_SLAB_ENTRIES_PER_CONNECTION`] = [2, 32]. Slab
3353    /// capacity is `10 + slab_entries_per_connection * max_connections`.
3354    #[serde(default)]
3355    pub slab_entries_per_connection: Option<u64>,
3356    /// Optional allowlist of UIDs permitted to invoke command-socket
3357    /// requests. `None` keeps the historical "any same-UID local process"
3358    /// behaviour. When `Some`, every request whose `SO_PEERCRED` UID is
3359    /// not in the list is rejected before reaching dispatch.
3360    #[serde(default)]
3361    pub command_allowed_uids: Option<Vec<u32>>,
3362    /// Maximum length, in bytes, of a base64-decoded `Authorization: Basic`
3363    /// payload accepted by `mux::auth`. `None` keeps the compile-time
3364    /// default of 4096. Set once on each worker at boot via
3365    /// [`ServerConfig::basic_auth_max_credential_bytes`].
3366    #[serde(default)]
3367    pub basic_auth_max_credential_bytes: Option<u64>,
3368    /// Default per-(cluster, source-IP) connection limit. `0` means
3369    /// unlimited. Each cluster may override via its own
3370    /// `max_connections_per_ip`. Source IP attribution honours the
3371    /// proxy-protocol header when present.
3372    #[serde(default = "default_max_connections_per_ip")]
3373    pub max_connections_per_ip: u64,
3374    /// Default `Retry-After` header value (seconds) emitted on HTTP 429
3375    /// responses. `0` omits the header.
3376    #[serde(default = "default_retry_after")]
3377    pub retry_after: u32,
3378    /// Requested kernel-pipe capacity, in bytes, for each `splice(2)`
3379    /// zero-copy direction. `None` keeps the kernel default of 64 KiB.
3380    /// Applied via `fcntl(F_SETPIPE_SZ)` per pipe at `SplicePipe::new`;
3381    /// the kernel rounds up to a page boundary and clamps at
3382    /// `/proc/sys/fs/pipe-max-size`. Linux-only; ignored on builds
3383    /// without the `splice` feature.
3384    #[serde(default)]
3385    pub splice_pipe_capacity_bytes: Option<u64>,
3386}
3387
3388fn default_front_timeout() -> u32 {
3389    DEFAULT_FRONT_TIMEOUT
3390}
3391
3392fn default_back_timeout() -> u32 {
3393    DEFAULT_BACK_TIMEOUT
3394}
3395
3396fn default_connect_timeout() -> u32 {
3397    DEFAULT_CONNECT_TIMEOUT
3398}
3399
3400fn default_request_timeout() -> u32 {
3401    DEFAULT_REQUEST_TIMEOUT
3402}
3403
3404fn default_zombie_check_interval() -> u32 {
3405    DEFAULT_ZOMBIE_CHECK_INTERVAL
3406}
3407
3408fn default_accept_queue_timeout() -> u32 {
3409    DEFAULT_ACCEPT_QUEUE_TIMEOUT
3410}
3411
3412fn default_evict_on_queue_full() -> bool {
3413    DEFAULT_EVICT_ON_QUEUE_FULL
3414}
3415
3416fn default_disable_cluster_metrics() -> bool {
3417    DEFAULT_DISABLE_CLUSTER_METRICS
3418}
3419
3420fn default_worker_timeout() -> u32 {
3421    DEFAULT_WORKER_TIMEOUT
3422}
3423
3424fn default_max_connections_per_ip() -> u64 {
3425    DEFAULT_MAX_CONNECTIONS_PER_IP
3426}
3427
3428fn default_retry_after() -> u32 {
3429    DEFAULT_RETRY_AFTER
3430}
3431
3432impl Config {
3433    /// Parse a TOML file and build a config out of it
3434    pub fn load_from_path(path: &str) -> Result<Config, ConfigError> {
3435        let file_config = FileConfig::load_from_path(path)?;
3436
3437        let mut config = ConfigBuilder::new(file_config, path).into_config()?;
3438
3439        // replace saved_state with a verified path
3440        config.saved_state = config.saved_state_path()?;
3441
3442        Ok(config)
3443    }
3444
3445    /// yields requests intended to recreate a proxy that match the config
3446    pub fn generate_config_messages(&self) -> Result<Vec<WorkerRequest>, ConfigError> {
3447        let mut v = Vec::new();
3448        let mut count = 0u8;
3449
3450        for listener in &self.http_listeners {
3451            v.push(WorkerRequest {
3452                id: format!("CONFIG-{count}"),
3453                content: RequestType::AddHttpListener(listener.clone()).into(),
3454            });
3455            count += 1;
3456        }
3457
3458        for listener in &self.https_listeners {
3459            v.push(WorkerRequest {
3460                id: format!("CONFIG-{count}"),
3461                content: RequestType::AddHttpsListener(listener.clone()).into(),
3462            });
3463            count += 1;
3464        }
3465
3466        for listener in &self.tcp_listeners {
3467            v.push(WorkerRequest {
3468                id: format!("CONFIG-{count}"),
3469                content: RequestType::AddTcpListener(*listener).into(),
3470            });
3471            count += 1;
3472        }
3473
3474        for listener in &self.udp_listeners {
3475            v.push(WorkerRequest {
3476                id: format!("CONFIG-{count}"),
3477                content: RequestType::AddUdpListener(*listener).into(),
3478            });
3479            count += 1;
3480        }
3481
3482        for cluster in self.clusters.values() {
3483            let mut orders = cluster.generate_requests()?;
3484            for content in orders.drain(..) {
3485                v.push(WorkerRequest {
3486                    id: format!("CONFIG-{count}"),
3487                    content,
3488                });
3489                count += 1;
3490            }
3491        }
3492
3493        if self.activate_listeners {
3494            for listener in &self.http_listeners {
3495                v.push(WorkerRequest {
3496                    id: format!("CONFIG-{count}"),
3497                    content: RequestType::ActivateListener(ActivateListener {
3498                        address: listener.address,
3499                        proxy: ListenerType::Http.into(),
3500                        from_scm: false,
3501                    })
3502                    .into(),
3503                });
3504                count += 1;
3505            }
3506
3507            for listener in &self.https_listeners {
3508                v.push(WorkerRequest {
3509                    id: format!("CONFIG-{count}"),
3510                    content: RequestType::ActivateListener(ActivateListener {
3511                        address: listener.address,
3512                        proxy: ListenerType::Https.into(),
3513                        from_scm: false,
3514                    })
3515                    .into(),
3516                });
3517                count += 1;
3518            }
3519
3520            for listener in &self.tcp_listeners {
3521                v.push(WorkerRequest {
3522                    id: format!("CONFIG-{count}"),
3523                    content: RequestType::ActivateListener(ActivateListener {
3524                        address: listener.address,
3525                        proxy: ListenerType::Tcp.into(),
3526                        from_scm: false,
3527                    })
3528                    .into(),
3529                });
3530                count += 1;
3531            }
3532
3533            for listener in &self.udp_listeners {
3534                v.push(WorkerRequest {
3535                    id: format!("CONFIG-{count}"),
3536                    content: RequestType::ActivateListener(ActivateListener {
3537                        address: listener.address,
3538                        proxy: ListenerType::Udp.into(),
3539                        from_scm: false,
3540                    })
3541                    .into(),
3542                });
3543                count += 1;
3544            }
3545        }
3546
3547        if self.disable_cluster_metrics {
3548            v.push(WorkerRequest {
3549                id: format!("CONFIG-{count}"),
3550                content: RequestType::ConfigureMetrics(MetricsConfiguration::Disabled.into())
3551                    .into(),
3552            });
3553            // count += 1; // uncomment if code is added below
3554        }
3555
3556        Ok(v)
3557    }
3558
3559    /// Get the path of the UNIX socket used to communicate with Sōzu
3560    pub fn command_socket_path(&self) -> Result<String, ConfigError> {
3561        let config_path_buf = PathBuf::from(self.config_path.clone());
3562        let mut config_dir = config_path_buf
3563            .parent()
3564            .ok_or(ConfigError::NoFileParent(
3565                config_path_buf.to_string_lossy().to_string(),
3566            ))?
3567            .to_path_buf();
3568
3569        let socket_path = PathBuf::from(self.command_socket.clone());
3570
3571        let mut socket_parent_dir = match socket_path.parent() {
3572            // if the socket path is of the form "./sozu.sock",
3573            // then the parent is the directory where config.toml is situated
3574            None => config_dir,
3575            Some(path) => {
3576                // concatenate the config directory and the relative path of the socket
3577                config_dir.push(path);
3578                // canonicalize to remove double dots like /path/to/config/directory/../../path/to/socket/directory/
3579                config_dir.canonicalize().map_err(|io_error| {
3580                    ConfigError::SocketPathError(format!(
3581                        "Could not canonicalize path {config_dir:?}: {io_error}"
3582                    ))
3583                })?
3584            }
3585        };
3586
3587        let socket_name = socket_path
3588            .file_name()
3589            .ok_or(ConfigError::SocketPathError(format!(
3590                "could not get command socket file name from {socket_path:?}"
3591            )))?;
3592
3593        // concatenate parent directory and socket file name
3594        socket_parent_dir.push(socket_name);
3595
3596        let command_socket_path = socket_parent_dir
3597            .to_str()
3598            .ok_or(ConfigError::SocketPathError(format!(
3599                "Invalid socket path {socket_parent_dir:?}"
3600            )))?
3601            .to_string();
3602
3603        Ok(command_socket_path)
3604    }
3605
3606    /// Get the path of where the state will be saved
3607    fn saved_state_path(&self) -> Result<Option<String>, ConfigError> {
3608        let path = match self.saved_state.as_ref() {
3609            Some(path) => path,
3610            None => return Ok(None),
3611        };
3612
3613        debug!("saved_stated path in the config: {}", path);
3614        let config_path = PathBuf::from(self.config_path.clone());
3615
3616        debug!("Config path buffer: {:?}", config_path);
3617        let config_dir = config_path
3618            .parent()
3619            .ok_or(ConfigError::SaveStatePath(format!(
3620                "Could get parent directory of config file {config_path:?}"
3621            )))?;
3622
3623        debug!("Config folder: {:?}", config_dir);
3624        if !config_dir.exists() {
3625            create_dir_all(config_dir).map_err(|io_error| {
3626                ConfigError::SaveStatePath(format!(
3627                    "failed to create state parent directory '{config_dir:?}': {io_error}"
3628                ))
3629            })?;
3630        }
3631
3632        let mut saved_state_path_raw = config_dir.to_path_buf();
3633        saved_state_path_raw.push(path);
3634        debug!(
3635            "Looking for saved state on the path {:?}",
3636            saved_state_path_raw
3637        );
3638
3639        match metadata(path) {
3640            Err(err) if matches!(err.kind(), ErrorKind::NotFound) => {
3641                info!("Create an empty state file at '{}'", path);
3642                File::create(path).map_err(|io_error| {
3643                    ConfigError::SaveStatePath(format!(
3644                        "failed to create state file '{path:?}': {io_error}"
3645                    ))
3646                })?;
3647            }
3648            _ => {}
3649        }
3650
3651        saved_state_path_raw.canonicalize().map_err(|io_error| {
3652            ConfigError::SaveStatePath(format!(
3653                "could not get saved state path from config file input {path:?}: {io_error}"
3654            ))
3655        })?;
3656
3657        let stringified_path = saved_state_path_raw
3658            .to_str()
3659            .ok_or(ConfigError::SaveStatePath(format!(
3660                "Invalid path {saved_state_path_raw:?}"
3661            )))?
3662            .to_string();
3663
3664        Ok(Some(stringified_path))
3665    }
3666
3667    /// read any file to a string
3668    pub fn load_file(path: &str) -> Result<String, ConfigError> {
3669        std::fs::read_to_string(path).map_err(|io_error| ConfigError::FileRead {
3670            path_to_read: path.to_owned(),
3671            io_error,
3672        })
3673    }
3674
3675    /// read any file to bytes
3676    pub fn load_file_bytes(path: &str) -> Result<Vec<u8>, ConfigError> {
3677        std::fs::read(path).map_err(|io_error| ConfigError::FileRead {
3678            path_to_read: path.to_owned(),
3679            io_error,
3680        })
3681    }
3682}
3683
3684impl fmt::Debug for Config {
3685    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3686        f.debug_struct("Config")
3687            .field("config_path", &self.config_path)
3688            .field("command_socket", &self.command_socket)
3689            .field("command_buffer_size", &self.command_buffer_size)
3690            .field("max_command_buffer_size", &self.max_command_buffer_size)
3691            .field("max_connections", &self.max_connections)
3692            .field("min_buffers", &self.min_buffers)
3693            .field("max_buffers", &self.max_buffers)
3694            .field("buffer_size", &self.buffer_size)
3695            .field("saved_state", &self.saved_state)
3696            .field("automatic_state_save", &self.automatic_state_save)
3697            .field("log_level", &self.log_level)
3698            .field("log_target", &self.log_target)
3699            .field("access_logs_target", &self.access_logs_target)
3700            .field("audit_logs_target", &self.audit_logs_target)
3701            .field("audit_logs_json_target", &self.audit_logs_json_target)
3702            .field("access_logs_format", &self.access_logs_format)
3703            .field("worker_count", &self.worker_count)
3704            .field("worker_automatic_restart", &self.worker_automatic_restart)
3705            .field("metrics", &self.metrics)
3706            .field("disable_cluster_metrics", &self.disable_cluster_metrics)
3707            .field("handle_process_affinity", &self.handle_process_affinity)
3708            .field("ctl_command_timeout", &self.ctl_command_timeout)
3709            .field("pid_file_path", &self.pid_file_path)
3710            .field("activate_listeners", &self.activate_listeners)
3711            .field("front_timeout", &self.front_timeout)
3712            .field("back_timeout", &self.back_timeout)
3713            .field("connect_timeout", &self.connect_timeout)
3714            .field("zombie_check_interval", &self.zombie_check_interval)
3715            .field("accept_queue_timeout", &self.accept_queue_timeout)
3716            .field("evict_on_queue_full", &self.evict_on_queue_full)
3717            .field("request_timeout", &self.request_timeout)
3718            .field("worker_timeout", &self.worker_timeout)
3719            .finish()
3720    }
3721}
3722
3723fn display_toml_error(file: &str, error: &toml::de::Error) {
3724    println!("error parsing the configuration file '{file}': {error}");
3725    if let Some(Range { start, end }) = error.span() {
3726        print!("error parsing the configuration file '{file}' at position: {start}, {end}");
3727    }
3728}
3729
3730impl ServerConfig {
3731    /// Default number of slab entries per connection. Set to 4 to accommodate
3732    /// H2 multiplexing (1 frontend + up to 3 backend connections per
3733    /// frontend with stream multiplexing). Previous value was 2 for H1-only
3734    /// operation. Operators with topologies that fan out across more
3735    /// clusters per session can override via `slab_entries_per_connection`
3736    /// in the config (clamped to [2, 32]).
3737    pub const DEFAULT_SLAB_ENTRIES_PER_CONNECTION: u64 = 4;
3738    /// Lower bound for the runtime knob. Below 2 the slab cannot hold one
3739    /// frontend + one backend per session.
3740    pub const MIN_SLAB_ENTRIES_PER_CONNECTION: u64 = 2;
3741    /// Upper bound for the runtime knob. 32 caps memory blow-up from a
3742    /// runaway config; 32 backends per frontend covers any sane topology.
3743    pub const MAX_SLAB_ENTRIES_PER_CONNECTION: u64 = 32;
3744
3745    /// Effective slab-entries-per-connection. Applies the [MIN, MAX] clamp
3746    /// and falls back to the default when the proto field is absent or 0.
3747    pub fn effective_slab_entries_per_connection(&self) -> u64 {
3748        let effective = match self.slab_entries_per_connection {
3749            Some(0) | None => Self::DEFAULT_SLAB_ENTRIES_PER_CONNECTION,
3750            Some(n) => n.clamp(
3751                Self::MIN_SLAB_ENTRIES_PER_CONNECTION,
3752                Self::MAX_SLAB_ENTRIES_PER_CONNECTION,
3753            ),
3754        };
3755        // POST: the effective value is always inside the documented clamp
3756        // window [MIN, MAX], regardless of the raw config input. The default
3757        // itself sits inside that window, so every branch satisfies the bound.
3758        debug_assert!(
3759            (Self::MIN_SLAB_ENTRIES_PER_CONNECTION..=Self::MAX_SLAB_ENTRIES_PER_CONNECTION)
3760                .contains(&effective),
3761            "effective slab entries per connection must stay within [MIN, MAX]"
3762        );
3763        effective
3764    }
3765
3766    /// Size of the slab for the Session manager.
3767    ///
3768    /// With HTTP/2 multiplexing, each frontend session can have multiple backend
3769    /// connections (one per cluster), so we allocate
3770    /// [`Self::effective_slab_entries_per_connection`] entries per connection
3771    /// instead of the old H1-only multiplier of 2.
3772    pub fn slab_capacity(&self) -> u64 {
3773        let per_conn = self.effective_slab_entries_per_connection();
3774        let capacity = 10 + per_conn * self.max_connections;
3775        // POST: the slab always reserves the 10-entry base (listeners, command
3776        // channel, etc.) plus at least MIN entries per connection, so it can
3777        // never be smaller than the base. Strict `>` for any non-zero
3778        // max_connections since per_conn >= MIN >= 2.
3779        debug_assert!(
3780            capacity >= 10,
3781            "slab capacity must reserve the base entries"
3782        );
3783        debug_assert!(
3784            self.max_connections == 0 || capacity > 10,
3785            "a non-zero connection cap must reserve per-connection slab entries"
3786        );
3787        capacity
3788    }
3789}
3790
3791/// reduce the config to the bare minimum needed by a worker
3792impl From<&Config> for ServerConfig {
3793    fn from(config: &Config) -> Self {
3794        let metrics = config.metrics.clone().map(|m| ServerMetricsConfig {
3795            address: m.address.to_string(),
3796            tagged_metrics: m.tagged_metrics,
3797            prefix: m.prefix,
3798            detail: Some(MetricDetail::from(m.detail) as i32),
3799        });
3800        let server_config = Self {
3801            max_connections: config.max_connections as u64,
3802            front_timeout: config.front_timeout,
3803            back_timeout: config.back_timeout,
3804            connect_timeout: config.connect_timeout,
3805            zombie_check_interval: config.zombie_check_interval,
3806            accept_queue_timeout: config.accept_queue_timeout,
3807            min_buffers: config.min_buffers,
3808            max_buffers: config.max_buffers,
3809            buffer_size: config.buffer_size,
3810            log_level: config.log_level.clone(),
3811            log_target: config.log_target.clone(),
3812            access_logs_target: config.access_logs_target.clone(),
3813            audit_logs_target: config.audit_logs_target.clone(),
3814            audit_logs_json_target: config.audit_logs_json_target.clone(),
3815            command_buffer_size: config.command_buffer_size,
3816            max_command_buffer_size: config.max_command_buffer_size,
3817            metrics,
3818            access_log_format: ProtobufAccessLogFormat::from(&config.access_logs_format) as i32,
3819            log_colored: config.log_colored,
3820            slab_entries_per_connection: config.slab_entries_per_connection,
3821            basic_auth_max_credential_bytes: config.basic_auth_max_credential_bytes,
3822            evict_on_queue_full: Some(config.evict_on_queue_full),
3823            max_connections_per_ip: Some(config.max_connections_per_ip),
3824            retry_after: Some(config.retry_after),
3825            splice_pipe_capacity_bytes: config.splice_pipe_capacity_bytes,
3826        };
3827
3828        // POST: the worker-facing config preserves the buffer-pool invariant
3829        // (min <= max) and carries the sizing knobs through unchanged — a
3830        // worker derives its slab and buffer pool straight from these, so any
3831        // drift here would desynchronize the master's view from the worker's.
3832        debug_assert!(
3833            server_config.min_buffers <= server_config.max_buffers,
3834            "ServerConfig must preserve min_buffers <= max_buffers"
3835        );
3836        debug_assert_eq!(
3837            server_config.buffer_size, config.buffer_size,
3838            "ServerConfig buffer_size must mirror the source config"
3839        );
3840        debug_assert_eq!(
3841            server_config.max_connections, config.max_connections as u64,
3842            "ServerConfig max_connections must mirror the source config"
3843        );
3844        server_config
3845    }
3846}
3847
3848#[cfg(test)]
3849mod tests {
3850    use toml::to_string;
3851
3852    use super::*;
3853
3854    #[test]
3855    fn hsts_to_proto_enabled_substitutes_default_max_age() {
3856        let cfg = FileHstsConfig {
3857            enabled: Some(true),
3858            max_age: None,
3859            include_subdomains: None,
3860            preload: None,
3861            force_replace_backend: None,
3862        };
3863        let proto = cfg.to_proto("test").expect("should validate");
3864        assert_eq!(proto.enabled, Some(true));
3865        assert_eq!(proto.max_age, Some(DEFAULT_HSTS_MAX_AGE));
3866    }
3867
3868    #[test]
3869    fn hsts_to_proto_explicit_max_age_kept() {
3870        let cfg = FileHstsConfig {
3871            enabled: Some(true),
3872            max_age: Some(63_072_000),
3873            include_subdomains: Some(true),
3874            preload: Some(true),
3875            force_replace_backend: None,
3876        };
3877        let proto = cfg.to_proto("test").expect("should validate");
3878        assert_eq!(proto.max_age, Some(63_072_000));
3879        assert_eq!(proto.include_subdomains, Some(true));
3880        assert_eq!(proto.preload, Some(true));
3881    }
3882
3883    #[test]
3884    fn hsts_to_proto_disabled_keeps_zero_intent() {
3885        // `enabled = false` means "explicit disable" — the materialiser
3886        // in `Frontend::new` won't append an edit, so the proto still
3887        // round-trips with `enabled = Some(false)`.
3888        let cfg = FileHstsConfig {
3889            enabled: Some(false),
3890            max_age: None,
3891            include_subdomains: None,
3892            preload: None,
3893            force_replace_backend: None,
3894        };
3895        let proto = cfg.to_proto("test").expect("should validate");
3896        assert_eq!(proto.enabled, Some(false));
3897    }
3898
3899    #[test]
3900    fn hsts_to_proto_kill_switch_max_age_zero_allowed() {
3901        // RFC 6797 §11.4: `max-age=0` instructs the UA to "cease
3902        // regarding the host as a Known HSTS Host". Explicit operator
3903        // intent — must NOT warn or fail.
3904        let cfg = FileHstsConfig {
3905            enabled: Some(true),
3906            max_age: Some(0),
3907            include_subdomains: None,
3908            preload: None,
3909            force_replace_backend: None,
3910        };
3911        let proto = cfg.to_proto("test").expect("kill-switch must validate");
3912        assert_eq!(proto.max_age, Some(0));
3913    }
3914
3915    #[test]
3916    fn hsts_to_proto_missing_enabled_errors() {
3917        let cfg = FileHstsConfig {
3918            enabled: None,
3919            max_age: Some(31_536_000),
3920            include_subdomains: None,
3921            preload: None,
3922            force_replace_backend: None,
3923        };
3924        match cfg.to_proto("test").unwrap_err() {
3925            ConfigError::HstsEnabledRequired(scope) => assert_eq!(scope, "test"),
3926            other => panic!("expected HstsEnabledRequired, got {other:?}"),
3927        }
3928    }
3929
3930    #[test]
3931    fn hsts_rejected_on_http_listener() {
3932        // RFC 6797 §7.2: an [hsts] block on an HTTP listener must be
3933        // rejected at TOML config-load — `HttpListenerConfig` carries no
3934        // `hsts` field and silently dropping the operator's intent
3935        // would be a worse failure mode than a typed error.
3936        let mut listener = ListenerBuilder::new(
3937            SocketAddress::new_v4(127, 0, 0, 1, 8080),
3938            ListenerProtocol::Http,
3939        );
3940        listener.hsts = Some(FileHstsConfig {
3941            enabled: Some(true),
3942            max_age: Some(31_536_000),
3943            include_subdomains: None,
3944            preload: None,
3945            force_replace_backend: None,
3946        });
3947        match listener.to_http(None).unwrap_err() {
3948            ConfigError::HstsOnPlainHttp(scope) => assert!(
3949                scope.contains("HTTP listener"),
3950                "expected scope to mention 'HTTP listener', got {scope:?}"
3951            ),
3952            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
3953        }
3954    }
3955
3956    #[test]
3957    fn hsts_rejected_on_http_frontend() {
3958        // A `FileClusterFrontendConfig` without a key+certificate pair
3959        // generates `RequestType::AddHttpFrontend` in
3960        // `HttpFrontendConfig::generate_requests`. RFC 6797 §7.2 forbids
3961        // HSTS on plaintext HTTP, so an `[hsts]` block on a cert-less
3962        // (HTTP-bound) frontend must be rejected at TOML config-load.
3963        let frontend = FileClusterFrontendConfig {
3964            address: "127.0.0.1:8080".parse().unwrap(),
3965            hostname: Some("example.com".to_owned()),
3966            path: None,
3967            path_type: None,
3968            method: None,
3969            certificate: None,
3970            key: None,
3971            certificate_chain: None,
3972            tls_versions: vec![],
3973            position: RulePosition::Tree,
3974            tags: None,
3975            redirect: None,
3976            redirect_scheme: None,
3977            redirect_template: None,
3978            rewrite_host: None,
3979            rewrite_path: None,
3980            rewrite_port: None,
3981            required_auth: None,
3982            headers: None,
3983            hsts: Some(FileHstsConfig {
3984                enabled: Some(true),
3985                max_age: Some(31_536_000),
3986                include_subdomains: None,
3987                preload: None,
3988                force_replace_backend: None,
3989            }),
3990        };
3991        match frontend.to_http_front("api").unwrap_err() {
3992            ConfigError::HstsOnPlainHttp(scope) => {
3993                assert!(
3994                    scope.contains("api") && scope.contains("example.com"),
3995                    "expected scope to mention 'api' and 'example.com', got {scope:?}"
3996                );
3997            }
3998            other => panic!("expected HstsOnPlainHttp, got {other:?}"),
3999        }
4000    }
4001
4002    #[test]
4003    fn serialize() {
4004        let http = ListenerBuilder::new(
4005            SocketAddress::new_v4(127, 0, 0, 1, 8080),
4006            ListenerProtocol::Http,
4007        )
4008        .with_answer_404_path(Some("404.html"))
4009        .to_owned();
4010        println!("http: {:?}", to_string(&http));
4011
4012        let https = ListenerBuilder::new(
4013            SocketAddress::new_v4(127, 0, 0, 1, 8443),
4014            ListenerProtocol::Https,
4015        )
4016        .with_answer_404_path(Some("404.html"))
4017        .to_owned();
4018        println!("https: {:?}", to_string(&https));
4019
4020        let listeners = vec![http, https];
4021        let config = FileConfig {
4022            command_socket: Some(String::from("./command_folder/sock")),
4023            worker_count: Some(2),
4024            worker_automatic_restart: Some(true),
4025            max_connections: Some(500),
4026            min_buffers: Some(1),
4027            max_buffers: Some(500),
4028            buffer_size: Some(16393),
4029            metrics: Some(MetricsConfig {
4030                address: "127.0.0.1:8125".parse().unwrap(),
4031                tagged_metrics: false,
4032                prefix: Some(String::from("sozu-metrics")),
4033                detail: MetricDetailLevel::default(),
4034            }),
4035            listeners: Some(listeners),
4036            ..Default::default()
4037        };
4038
4039        println!("config: {:?}", to_string(&config));
4040        let encoded = to_string(&config).unwrap();
4041        println!("conf:\n{encoded}");
4042    }
4043
4044    #[test]
4045    fn parse() {
4046        let path = "assets/config.toml";
4047        let config = Config::load_from_path(path).unwrap_or_else(|load_error| {
4048            panic!("Cannot load config from path {path}: {load_error:?}")
4049        });
4050        println!("config: {config:#?}");
4051        //panic!();
4052    }
4053
4054    #[test]
4055    fn multiple_listeners_preserve_per_address_expect_proxy() {
4056        let toml_content = r#"
4057            command_socket = "/tmp/sozu_test.sock"
4058            worker_count = 1
4059
4060            [[listeners]]
4061            protocol = "http"
4062            address = "172.16.20.1:80"
4063            expect_proxy = true
4064
4065            [[listeners]]
4066            protocol = "http"
4067            address = "10.22.0.1:80"
4068            expect_proxy = false
4069
4070            [[listeners]]
4071            protocol = "https"
4072            address = "192.168.1.1:443"
4073            expect_proxy = true
4074
4075            [[listeners]]
4076            protocol = "https"
4077            address = "192.168.2.1:443"
4078            expect_proxy = false
4079        "#;
4080
4081        let file_config: FileConfig =
4082            toml::from_str(toml_content).expect("Could not parse TOML config");
4083
4084        let listeners = file_config.listeners.as_ref().expect("No listeners found");
4085        assert_eq!(listeners.len(), 4);
4086
4087        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
4088            .into_config()
4089            .expect("Could not build config");
4090
4091        assert_eq!(config.http_listeners.len(), 2);
4092        assert_eq!(config.https_listeners.len(), 2);
4093
4094        // HTTP listeners
4095        let http_proxy = config
4096            .http_listeners
4097            .iter()
4098            .find(|l| SocketAddr::from(l.address) == "172.16.20.1:80".parse().unwrap())
4099            .expect("Listener on 172.16.20.1:80 not found");
4100        let http_direct = config
4101            .http_listeners
4102            .iter()
4103            .find(|l| SocketAddr::from(l.address) == "10.22.0.1:80".parse().unwrap())
4104            .expect("Listener on 10.22.0.1:80 not found");
4105
4106        assert!(http_proxy.expect_proxy);
4107        assert!(!http_direct.expect_proxy);
4108
4109        // HTTPS listeners
4110        let https_proxy = config
4111            .https_listeners
4112            .iter()
4113            .find(|l| SocketAddr::from(l.address) == "192.168.1.1:443".parse().unwrap())
4114            .expect("Listener on 192.168.1.1:443 not found");
4115        let https_direct = config
4116            .https_listeners
4117            .iter()
4118            .find(|l| SocketAddr::from(l.address) == "192.168.2.1:443".parse().unwrap())
4119            .expect("Listener on 192.168.2.1:443 not found");
4120
4121        assert!(https_proxy.expect_proxy);
4122        assert!(!https_direct.expect_proxy);
4123    }
4124
4125    #[test]
4126    fn multiple_listeners_generate_correct_worker_requests() {
4127        let toml_content = r#"
4128            command_socket = "/tmp/sozu_test.sock"
4129            worker_count = 1
4130            activate_listeners = true
4131
4132            [[listeners]]
4133            protocol = "http"
4134            address = "172.16.20.1:80"
4135            expect_proxy = true
4136
4137            [[listeners]]
4138            protocol = "http"
4139            address = "10.22.0.1:80"
4140            expect_proxy = false
4141        "#;
4142
4143        let file_config: FileConfig =
4144            toml::from_str(toml_content).expect("Could not parse TOML config");
4145
4146        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
4147            .into_config()
4148            .expect("Could not build config");
4149
4150        let messages = config
4151            .generate_config_messages()
4152            .expect("Could not generate config messages");
4153
4154        let add_listener_count = messages
4155            .iter()
4156            .filter(|m| {
4157                matches!(
4158                    m.content.request_type,
4159                    Some(RequestType::AddHttpListener(_))
4160                )
4161            })
4162            .count();
4163
4164        let activate_listener_count = messages
4165            .iter()
4166            .filter(|m| {
4167                matches!(
4168                    m.content.request_type,
4169                    Some(RequestType::ActivateListener(ActivateListener {
4170                        proxy,
4171                        ..
4172                    })) if proxy == ListenerType::Http as i32
4173                )
4174            })
4175            .count();
4176
4177        assert_eq!(add_listener_count, 2);
4178        assert_eq!(activate_listener_count, 2);
4179    }
4180
4181    #[test]
4182    fn documented_udp_dns_example_loads_and_emits_udp_requests() {
4183        // The DNS example from doc/configure.md ("#### UDP clusters"): a
4184        // `protocol = "udp"` listener + a `protocol = "tcp"` cluster whose
4185        // frontend points at that UDP listener address, with datagram knobs
4186        // under `[clusters.dns.udp]`. This must load without a
4187        // `WrongFrontendProtocol` error and emit an `AddUdpListener` and an
4188        // `AddUdpFrontend` (not `AddTcpFrontend`).
4189        let toml_content = r#"
4190            command_socket = "/tmp/sozu_test.sock"
4191            worker_count = 1
4192            activate_listeners = true
4193
4194            [[listeners]]
4195            protocol = "udp"
4196            address  = "0.0.0.0:53"
4197
4198            [clusters.dns]
4199            protocol       = "tcp"
4200            load_balancing = "HRW"
4201            frontends = [
4202              { address = "0.0.0.0:53" }
4203            ]
4204            backends = [
4205              { address = "10.0.0.10:53" },
4206              { address = "10.0.0.11:53" }
4207            ]
4208
4209            [clusters.dns.udp]
4210            affinity_key        = "SOURCE_IP"
4211            responses           = 1
4212            requests            = 0
4213            send_proxy_protocol = true
4214
4215            [clusters.dns.udp.health]
4216            mode      = "TCP_PROBE"
4217            tcp_port  = 53
4218            rise      = 2
4219            fall      = 3
4220            fail_open = true
4221        "#;
4222
4223        let file_config: FileConfig =
4224            toml::from_str(toml_content).expect("Could not parse documented DNS TOML");
4225
4226        let config = ConfigBuilder::new(file_config, "/tmp/test_config.toml")
4227            .into_config()
4228            .expect("documented UDP DNS example must load without WrongFrontendProtocol");
4229
4230        // The UDP listener was registered.
4231        assert_eq!(
4232            config.udp_listeners.len(),
4233            1,
4234            "the protocol=\"udp\" listener must be built"
4235        );
4236
4237        let messages = config
4238            .generate_config_messages()
4239            .expect("Could not generate config messages");
4240
4241        let add_udp_listener_count = messages
4242            .iter()
4243            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpListener(_))))
4244            .count();
4245        assert_eq!(
4246            add_udp_listener_count, 1,
4247            "must emit exactly one AddUdpListener"
4248        );
4249
4250        let add_udp_frontend_count = messages
4251            .iter()
4252            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddUdpFrontend(_))))
4253            .count();
4254        assert_eq!(
4255            add_udp_frontend_count, 1,
4256            "the cluster frontend on the UDP listener must emit AddUdpFrontend"
4257        );
4258
4259        // It must NOT have been emitted as a TCP frontend.
4260        let add_tcp_frontend_count = messages
4261            .iter()
4262            .filter(|m| matches!(m.content.request_type, Some(RequestType::AddTcpFrontend(_))))
4263            .count();
4264        assert_eq!(
4265            add_tcp_frontend_count, 0,
4266            "a UDP-listener-addressed frontend must not be emitted as AddTcpFrontend"
4267        );
4268
4269        // The AddUdpFrontend carries the cluster id and address.
4270        let udp_frontend = messages
4271            .iter()
4272            .find_map(|m| match &m.content.request_type {
4273                Some(RequestType::AddUdpFrontend(f)) => Some(f),
4274                _ => None,
4275            })
4276            .expect("AddUdpFrontend must be present");
4277        assert_eq!(udp_frontend.cluster_id, "dns");
4278        assert_eq!(
4279            SocketAddr::from(udp_frontend.address),
4280            "0.0.0.0:53".parse().unwrap()
4281        );
4282
4283        // The UDP cluster knobs from [clusters.dns.udp] survive onto the
4284        // AddCluster request.
4285        let cluster = messages
4286            .iter()
4287            .find_map(|m| match &m.content.request_type {
4288                Some(RequestType::AddCluster(c)) if c.cluster_id == "dns" => Some(c),
4289                _ => None,
4290            })
4291            .expect("AddCluster for 'dns' must be present");
4292        let udp = cluster
4293            .udp
4294            .as_ref()
4295            .expect("[clusters.dns.udp] block must carry onto the cluster");
4296        assert_eq!(udp.responses, Some(1));
4297    }
4298
4299    #[test]
4300    fn duplicate_listener_address_rejected() {
4301        let toml_content = r#"
4302            command_socket = "/tmp/sozu_test.sock"
4303            worker_count = 1
4304
4305            [[listeners]]
4306            protocol = "http"
4307            address = "0.0.0.0:80"
4308
4309            [[listeners]]
4310            protocol = "http"
4311            address = "0.0.0.0:80"
4312        "#;
4313
4314        let file_config: FileConfig =
4315            toml::from_str(toml_content).expect("Could not parse TOML config");
4316
4317        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
4318
4319        assert!(
4320            result.is_err(),
4321            "Should reject duplicate listener addresses"
4322        );
4323    }
4324
4325    #[test]
4326    fn buffer_size_below_h2_minimum_rejected() {
4327        // Default ALPN ["h2", "http/1.1"] + buffer_size = 8192 must error.
4328        let toml_content = r#"
4329            command_socket = "/tmp/sozu_test.sock"
4330            worker_count = 1
4331            buffer_size = 8192
4332
4333            [[listeners]]
4334            protocol = "https"
4335            address = "127.0.0.1:8443"
4336        "#;
4337        let file_config: FileConfig =
4338            toml::from_str(toml_content).expect("Could not parse TOML config");
4339        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
4340        match result {
4341            Err(ConfigError::BufferSizeTooSmallForH2 {
4342                buffer_size: 8192,
4343                minimum: 16_393,
4344                listeners: 1,
4345            }) => {}
4346            other => panic!("expected BufferSizeTooSmallForH2, got {other:?}"),
4347        }
4348    }
4349
4350    #[test]
4351    fn buffer_size_below_h2_minimum_accepted_when_no_h2_listener() {
4352        // Drop "h2" from ALPN — buffer_size = 8192 is now valid.
4353        let toml_content = r#"
4354            command_socket = "/tmp/sozu_test.sock"
4355            worker_count = 1
4356            buffer_size = 8192
4357
4358            [[listeners]]
4359            protocol = "https"
4360            address = "127.0.0.1:8443"
4361            alpn_protocols = ["http/1.1"]
4362        "#;
4363        let file_config: FileConfig =
4364            toml::from_str(toml_content).expect("Could not parse TOML config");
4365        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
4366        assert!(
4367            result.is_ok(),
4368            "non-H2 HTTPS listener with sub-16393 buffer should be accepted: {result:?}"
4369        );
4370    }
4371
4372    #[test]
4373    fn buffer_size_at_h2_minimum_accepted() {
4374        let toml_content = r#"
4375            command_socket = "/tmp/sozu_test.sock"
4376            worker_count = 1
4377            buffer_size = 16393
4378
4379            [[listeners]]
4380            protocol = "https"
4381            address = "127.0.0.1:8443"
4382        "#;
4383        let file_config: FileConfig =
4384            toml::from_str(toml_content).expect("Could not parse TOML config");
4385        let result = ConfigBuilder::new(file_config, "/tmp/test_config.toml").into_config();
4386        assert!(
4387            result.is_ok(),
4388            "buffer_size at the H2 minimum should be accepted: {result:?}"
4389        );
4390    }
4391
4392    #[test]
4393    fn alpn_protocols_default() {
4394        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4395        let config = builder.to_tls(None).expect("to_tls should succeed");
4396        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
4397    }
4398
4399    #[test]
4400    fn alpn_protocols_custom() {
4401        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4402        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned()]));
4403        let config = builder.to_tls(None).expect("to_tls should succeed");
4404        assert_eq!(config.alpn_protocols, vec!["http/1.1"]);
4405    }
4406
4407    #[test]
4408    fn alpn_protocols_invalid_rejected() {
4409        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4410        builder.with_alpn_protocols(Some(vec!["h3".to_owned()]));
4411        let result = builder.to_tls(None);
4412        assert!(result.is_err());
4413        let err = result.unwrap_err();
4414        assert!(
4415            err.to_string().contains("h3"),
4416            "error should mention the invalid protocol: {err}"
4417        );
4418    }
4419
4420    #[test]
4421    fn alpn_protocols_empty_uses_default() {
4422        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4423        builder.with_alpn_protocols(Some(vec![]));
4424        let config = builder.to_tls(None).expect("to_tls should succeed");
4425        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
4426    }
4427
4428    #[test]
4429    fn alpn_protocols_deduplicated() {
4430        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4431        builder.with_alpn_protocols(Some(vec![
4432            "h2".to_owned(),
4433            "h2".to_owned(),
4434            "http/1.1".to_owned(),
4435        ]));
4436        let config = builder.to_tls(None).expect("to_tls should succeed");
4437        assert_eq!(config.alpn_protocols, vec!["h2", "http/1.1"]);
4438    }
4439
4440    #[test]
4441    fn alpn_protocols_order_preserved() {
4442        let mut builder = ListenerBuilder::new_https(SocketAddress::new_v4(127, 0, 0, 1, 8443));
4443        builder.with_alpn_protocols(Some(vec!["http/1.1".to_owned(), "h2".to_owned()]));
4444        let config = builder.to_tls(None).expect("to_tls should succeed");
4445        assert_eq!(config.alpn_protocols, vec!["http/1.1", "h2"]);
4446    }
4447
4448    /// CRLF or NUL in a `[[clusters.<id>.frontends.headers]]` value
4449    /// would let an operator-supplied config splice arbitrary
4450    /// header / request lines into the H1 wire on the backend side
4451    /// (CWE-113). The H2 emission path filters at runtime; we reject
4452    /// at config-load time as a defense in depth.
4453    #[test]
4454    fn parse_header_edit_rejects_crlf_in_value() {
4455        let entry = HeaderEditConfig {
4456            position: "request".to_owned(),
4457            key: "X-Test".to_owned(),
4458            value: "value\r\nEvil-Header: stolen".to_owned(),
4459        };
4460        let err = parse_header_edit(0, &entry).expect_err("CRLF in value must be rejected");
4461        match err {
4462            ConfigError::InvalidHeaderBytes { index, field } => {
4463                assert_eq!(index, 0);
4464                assert_eq!(field, "value");
4465            }
4466            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
4467        }
4468    }
4469
4470    #[test]
4471    fn parse_header_edit_rejects_lf_in_key() {
4472        let entry = HeaderEditConfig {
4473            position: "response".to_owned(),
4474            key: "X-\nTest".to_owned(),
4475            value: "ok".to_owned(),
4476        };
4477        let err = parse_header_edit(2, &entry).expect_err("LF in key must be rejected");
4478        match err {
4479            ConfigError::InvalidHeaderBytes { index, field } => {
4480                assert_eq!(index, 2);
4481                assert_eq!(field, "key");
4482            }
4483            other => panic!("expected InvalidHeaderBytes, got {other:?}"),
4484        }
4485    }
4486
4487    #[test]
4488    fn parse_header_edit_rejects_nul() {
4489        let entry = HeaderEditConfig {
4490            position: "both".to_owned(),
4491            key: "X-Test".to_owned(),
4492            value: "with\0nul".to_owned(),
4493        };
4494        assert!(matches!(
4495            parse_header_edit(0, &entry),
4496            Err(ConfigError::InvalidHeaderBytes { .. })
4497        ));
4498    }
4499
4500    /// Horizontal tab `\t` (0x09) is permitted in field values per
4501    /// RFC 9110 §5.5 (folded-header obs-fold parts). The value-side
4502    /// validator must NOT reject it — otherwise legitimate operator
4503    /// configs (e.g. `Authorization: Basic\tCREDENTIALS`) become
4504    /// unusable. The key-side validator IS stricter (token grammar).
4505    #[test]
4506    fn parse_header_edit_accepts_tab_in_value() {
4507        let entry = HeaderEditConfig {
4508            position: "request".to_owned(),
4509            key: "X-Test".to_owned(),
4510            value: "with\ttab".to_owned(),
4511        };
4512        let header = parse_header_edit(0, &entry).expect("tab in value must be accepted");
4513        assert_eq!(header.val, "with\ttab");
4514    }
4515
4516    /// Header NAMES follow `token` grammar per RFC 9110 §5.1. HTAB and
4517    /// SP are NOT tchar; the key-side validator must reject them even
4518    /// though the value-side validator permits HTAB. Without this,
4519    /// an operator entry like `key = "Host\t"` would emit `Host\t: …`
4520    /// on the H1 wire and produce an invalid (but parser-tolerant)
4521    /// header line that some backends silently accept as `Host:`.
4522    #[test]
4523    fn parse_header_edit_rejects_tab_in_key() {
4524        let entry = HeaderEditConfig {
4525            position: "request".to_owned(),
4526            key: "Host\t".to_owned(),
4527            value: "ok".to_owned(),
4528        };
4529        let err = parse_header_edit(0, &entry).expect_err("HTAB in key must be rejected");
4530        match err {
4531            ConfigError::InvalidHeaderBytes { field, .. } => assert_eq!(field, "key"),
4532            other => panic!("expected InvalidHeaderBytes{{field=\"key\"}}, got {other:?}"),
4533        }
4534    }
4535
4536    #[test]
4537    fn parse_header_edit_rejects_space_in_key() {
4538        let entry = HeaderEditConfig {
4539            position: "request".to_owned(),
4540            key: "X Test".to_owned(),
4541            value: "ok".to_owned(),
4542        };
4543        let err = parse_header_edit(0, &entry).expect_err("SP in key must be rejected");
4544        assert!(matches!(err, ConfigError::InvalidHeaderBytes { .. }));
4545    }
4546
4547    #[test]
4548    fn parse_header_edit_rejects_empty_key() {
4549        let entry = HeaderEditConfig {
4550            position: "request".to_owned(),
4551            key: String::new(),
4552            value: "ok".to_owned(),
4553        };
4554        let err = parse_header_edit(0, &entry).expect_err("empty key must be rejected");
4555        assert!(matches!(
4556            err,
4557            ConfigError::InvalidHeaderBytes { field: "key", .. }
4558        ));
4559    }
4560
4561    #[test]
4562    fn parse_header_edit_accepts_clean_value() {
4563        let entry = HeaderEditConfig {
4564            position: "request".to_owned(),
4565            key: "X-Tenant".to_owned(),
4566            value: "alpha".to_owned(),
4567        };
4568        let header = parse_header_edit(0, &entry).expect("clean value must be accepted");
4569        assert_eq!(header.key, "X-Tenant");
4570        assert_eq!(header.val, "alpha");
4571    }
4572
4573    /// A bare string with no scheme prefix is the inline literal body.
4574    /// This is the common case — short canned responses inline in TOML
4575    /// or a `--answer` flag, no disk I/O.
4576    #[test]
4577    fn resolve_answer_source_bare_string_is_literal() {
4578        let body = resolve_answer_source("HTTP/1.1 503 Service Unavailable\r\n\r\nbusy")
4579            .expect("bare-string source must resolve");
4580        assert_eq!(body, "HTTP/1.1 503 Service Unavailable\r\n\r\nbusy");
4581    }
4582
4583    #[test]
4584    fn resolve_answer_source_empty_string_is_legitimate() {
4585        let body = resolve_answer_source("").expect("empty source must resolve");
4586        assert_eq!(body, "");
4587    }
4588
4589    /// `file://` opts into reading the path off disk. A non-existent
4590    /// path bubbles up as `ConfigError::FileOpen` so the operator gets
4591    /// the same diagnostics as the existing per-status `answer_NNN`
4592    /// flow.
4593    #[test]
4594    fn resolve_answer_source_file_scheme_missing_file_errors() {
4595        let err = resolve_answer_source("file:///nonexistent/sozu-test/never.http")
4596            .expect_err("missing path must error");
4597        assert!(matches!(err, ConfigError::FileOpen { .. }));
4598    }
4599
4600    /// `file://` strips the scheme; an empty path after the scheme is
4601    /// rejected (empty path on filesystem read).
4602    #[test]
4603    fn resolve_answer_source_file_scheme_empty_path_errors() {
4604        let err = resolve_answer_source("file://").expect_err("empty path must error");
4605        assert!(matches!(err, ConfigError::FileOpen { .. }));
4606    }
4607}