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