Skip to main content

sozu_command_lib/
config.rs

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