Skip to main content

churust_core/
config.rs

1//! Layered configuration: defaults < `churust.toml` < env (`CHURUST_*`) < code.
2
3use serde::Deserialize;
4
5/// The fully-resolved application configuration.
6///
7/// Deserializes from a `churust.toml` file (with a `[server]` table and an
8/// optional `[tls]` table) and can be overlaid with `CHURUST_*` environment
9/// variables via [`apply_env`](Config::apply_env). Apply it to a builder with
10/// [`AppBuilder::with_config`](crate::AppBuilder::with_config), or use
11/// [`Churust::from_config`](crate::Churust::from_config) to load and apply the
12/// defaults in one step. Unspecified fields fall back to sane defaults.
13///
14/// ```
15/// use churust_core::Config;
16///
17/// let cfg: Config = toml::from_str(r#"
18///     [server]
19///     host = "0.0.0.0"
20///     port = 9090
21/// "#).unwrap();
22/// assert_eq!(cfg.server.host, "0.0.0.0");
23/// assert_eq!(cfg.server.port, 9090);
24/// // unspecified fields keep their defaults
25/// assert_eq!(cfg.server.max_body_bytes, 1 << 20);
26/// assert!(cfg.tls.is_none());
27/// ```
28#[derive(Debug, Clone, Default, Deserialize)]
29#[serde(default)]
30pub struct Config {
31    /// Server-level settings (`[server]` table).
32    pub server: ServerSection,
33    /// Optional TLS settings (`[tls]` table); `None` serves plaintext HTTP.
34    pub tls: Option<TlsSection>,
35}
36
37/// The `[server]` configuration table.
38///
39/// Every field has a default (see [`ServerSection::default`]), so a partial
40/// `[server]` table is valid.
41#[derive(Debug, Clone, Deserialize)]
42#[serde(default)]
43pub struct ServerSection {
44    /// Bind address host (default `"127.0.0.1"`).
45    pub host: String,
46    /// Bind port (default `8080`).
47    pub port: u16,
48    /// Maximum accepted request body size in bytes (default `1 MiB`). Larger
49    /// bodies are rejected with `413 Payload Too Large`.
50    ///
51    /// A body whose `Content-Length` exceeds this is refused before the request
52    /// is dispatched, so a handler that never reads the body cannot serve one
53    /// anyway. A chunked body declares no length, so it is bounded as it is
54    /// read — which means a handler that ignores a chunked body will not notice
55    /// the cap. Reject on `Transfer-Encoding` explicitly if that matters.
56    ///
57    /// Raise [`request_timeout_ms`](Self::request_timeout_ms) alongside it: the
58    /// per-request timeout spans the upload, so a generous size cap paired with
59    /// a short timeout rejects large-but-legitimate transfers part-way through.
60    pub max_body_bytes: usize,
61    /// Per-request timeout in milliseconds (default `30000`). `0` disables the
62    /// timeout.
63    ///
64    /// **It bounds the whole exchange, including reading the request body.**
65    /// Bodies stream, so they are read inside the handler rather than before
66    /// it, and a slow upload consumes this budget however small it is. Raising
67    /// [`max_body_bytes`](Self::max_body_bytes) therefore means raising this in
68    /// step: a 100 MiB cap with a 30-second timeout rejects any client slower
69    /// than ~3.4 MiB/s, mid-transfer, with `408`.
70    pub request_timeout_ms: u64,
71    /// How long a connection may take to send its complete header block, in
72    /// milliseconds (default `10000`). `0` disables it.
73    ///
74    /// This is the slow-loris defence: without it a client can hold a
75    /// connection open indefinitely by dribbling one header byte at a time,
76    /// because the per-request timeout does not start until there is a request.
77    ///
78    /// It covers all three phases of a connection, by different means.
79    ///
80    /// Before either protocol exists, the server is still reading up to the 24
81    /// bytes of the HTTP/2 preface to find out which one to speak, and neither
82    /// mechanism below has been created yet. A connection that does not get as
83    /// far as choosing a protocol within this deadline is closed — which is
84    /// what bounds a peer that connects and then sends nothing at all.
85    ///
86    /// HTTP/1 then gets a literal deadline on the header block. HTTP/2 has no
87    /// equivalent — a header block arrives as frames on an already-open
88    /// connection — so the same value drives a keep-alive PING and the deadline
89    /// for its acknowledgement, which answers the equivalent question of
90    /// whether the peer is still there. A stalled h2 peer is therefore dropped
91    /// within roughly twice this value, and a live one with nothing to say
92    /// answers the ping and stays: once a protocol has been negotiated this
93    /// deadline no longer applies, so an idle-but-responsive client is not
94    /// bound by it.
95    pub header_read_timeout_ms: u64,
96    /// Maximum number of headers accepted on a request (default `100`).
97    pub max_headers: usize,
98    /// Maximum path segments accepted before a request is rejected with `414`
99    /// (default `64`).
100    ///
101    /// The router walks segments recursively with backtracking, so path depth
102    /// is a stack-depth question. hyper bounds the request line, which bounds
103    /// this in practice, but the bound should be Churust's own and stated
104    /// rather than inherited by accident.
105    pub max_path_segments: usize,
106    /// Maximum WebSocket frame size in bytes (default `1 MiB`). Requires the
107    /// `ws` feature.
108    pub ws_max_frame_bytes: usize,
109    /// How long an idle connection is kept open for reuse, in milliseconds
110    /// (default `75000`). `0` disables keep-alive: answer and close.
111    ///
112    /// Idle means *no request in flight* — a handler slower than this is busy,
113    /// not idle, and its connection is left alone. The timer restarts when a
114    /// request finishes.
115    pub keep_alive_ms: u64,
116    /// Listen backlog: connections the kernel may queue before the accept loop
117    /// reaches them (default `1024`).
118    pub backlog: u32,
119    /// How long graceful shutdown waits for in-flight requests to finish, in
120    /// milliseconds (default `30000`). `0` waits indefinitely.
121    ///
122    /// Without a bound, one slow request delays shutdown forever, which in a
123    /// container means being killed rather than exiting cleanly.
124    pub shutdown_timeout_ms: u64,
125    /// Maximum reassembled WebSocket message size in bytes (default `4 MiB`).
126    /// Requires the `ws` feature.
127    ///
128    /// Separate from the frame cap because a peer can send many small
129    /// continuation frames that reassemble into one enormous message.
130    pub ws_max_message_bytes: usize,
131    /// What to do with a non-canonical path spelling (default `"strict"`).
132    ///
133    /// One of `"strict"`, `"redirect"` or `"collapse"`. See
134    /// [`PathPolicy`](crate::PathPolicy).
135    pub path_policy: crate::path::PathPolicy,
136    /// Maximum size of a received HTTP/2 header block, in bytes (default
137    /// `16384`).
138    ///
139    /// The HTTP/2 counterpart of [`max_headers`](Self::max_headers), which
140    /// configures HTTP/1 only: h2 has no header *count*, it has an encoded
141    /// size. Exceeding it is refused at the protocol level.
142    pub h2_max_header_list_size: u32,
143    /// Maximum concurrent HTTP/2 streams per connection (default `200`). `0`
144    /// removes the limit.
145    ///
146    /// One h2 connection multiplexes many requests, so without this a single
147    /// connection is an unbounded amount of concurrent work — the shape behind
148    /// the HTTP/2 stream-flood denial-of-service family. hyper's own docs
149    /// encourage setting an explicit limit rather than inheriting its default.
150    pub h2_max_concurrent_streams: u32,
151    /// How long an upgraded WebSocket may sit with no traffic in either
152    /// direction before it is closed, in milliseconds (default `300000`, five
153    /// minutes). `0` disables the bound. Requires the `ws` feature.
154    ///
155    /// An upgraded socket holds a connection permit for its whole life, and
156    /// none of the HTTP-level bounds survive the upgrade —
157    /// [`header_read_timeout_ms`](Self::header_read_timeout_ms) applies before
158    /// there is a WebSocket and [`request_timeout_ms`](Self::request_timeout_ms)
159    /// wraps a request that has already completed. Without this, a peer that
160    /// completes the handshake and then says nothing pins a permit until the
161    /// process restarts.
162    pub ws_idle_timeout_ms: u64,
163    /// Maximum simultaneously served connections (default `25000`). `0` means
164    /// unlimited.
165    ///
166    /// The backlog bounds what the kernel queues; this bounds what the process
167    /// accepts. Without it, memory and file descriptors are limited only by
168    /// what the OS will hand out, and the failure mode is the whole process
169    /// dying rather than new connections waiting.
170    pub max_connections: usize,
171    /// Maximum TLS handshakes in progress at once (default `256`). `0` means
172    /// unlimited. Requires the `tls` feature.
173    ///
174    /// Much smaller than [`max_connections`](Self::max_connections) on purpose:
175    /// a handshake is asymmetric work — cheap to ask for, expensive to answer —
176    /// so it needs its own, tighter bound.
177    pub max_tls_handshakes: usize,
178    /// How long a TLS handshake may take before the connection is dropped, in
179    /// milliseconds (default `10000`). `0` disables the bound.
180    ///
181    /// [`header_read_timeout_ms`](Self::header_read_timeout_ms) cannot cover
182    /// this: before the handshake completes there is no HTTP layer to time out.
183    /// Without it, a client that completes the TCP handshake and then sends one
184    /// byte per minute holds a connection open indefinitely.
185    ///
186    /// The clock starts when the connection is accepted, so it covers waiting
187    /// for a [`max_tls_handshakes`](Self::max_tls_handshakes) permit as well as
188    /// the handshake itself. That is what makes it a bound on how long a peer
189    /// can hold a *connection* permit: timing only the handshake would let a
190    /// queue of stalled peers expire a permit's worth at a time while the rest
191    /// waited unbounded. A consequence worth knowing is that under genuine
192    /// handshake overload a legitimate client can be dropped while still
193    /// queued — the alternative is holding its connection slot instead.
194    pub tls_handshake_timeout_ms: u64,
195}
196
197/// The `[tls]` configuration table: paths to a PEM certificate chain and
198/// private key.
199///
200/// Only meaningful when the `tls` feature is enabled; otherwise the paths are
201/// ignored. Set it on a builder with [`AppBuilder::tls`](crate::AppBuilder::tls).
202#[derive(Debug, Clone, Deserialize)]
203pub struct TlsSection {
204    /// Path to the PEM-encoded certificate chain file.
205    pub cert: String,
206    /// Path to the PEM-encoded private key file.
207    pub key: String,
208}
209
210impl Default for ServerSection {
211    fn default() -> Self {
212        Self {
213            host: "127.0.0.1".into(),
214            port: 8080,
215            max_body_bytes: 1 << 20,
216            request_timeout_ms: 30_000,
217            header_read_timeout_ms: 10_000,
218            max_headers: 100,
219            max_path_segments: 64,
220            ws_max_frame_bytes: 1 << 20,
221            ws_max_message_bytes: 4 << 20,
222            keep_alive_ms: 75_000,
223            backlog: 1024,
224            shutdown_timeout_ms: 30_000,
225            path_policy: crate::path::PathPolicy::Strict,
226            h2_max_header_list_size: 16 << 10,
227            h2_max_concurrent_streams: 200,
228            ws_idle_timeout_ms: 300_000,
229            max_connections: 25_000,
230            max_tls_handshakes: 256,
231            tls_handshake_timeout_ms: 10_000,
232        }
233    }
234}
235
236impl Config {
237    /// Load configuration by layering, in increasing precedence: built-in
238    /// defaults, then the TOML file at `path` (if it exists and parses), then
239    /// `CHURUST_*` environment variables.
240    ///
241    /// A missing or unparseable file is treated as "use defaults" rather than an
242    /// error, so calling this without a config file present is safe.
243    ///
244    /// ```no_run
245    /// use churust_core::Config;
246    /// // Reads ./my-app.toml if present, then applies CHURUST_* env overrides.
247    /// let cfg = Config::load("my-app.toml");
248    /// let _ = cfg.server.port;
249    /// ```
250    pub fn load(path: &str) -> Self {
251        let mut cfg = match std::fs::read_to_string(path) {
252            // A malformed file still falls back to defaults — but says so.
253            // Silently substituting defaults discards the operator's host,
254            // port, limits and TLS paths over a single typo, and a server that
255            // came up on 127.0.0.1:8080 with no TLS because of an unreported
256            // parse error is the hardest kind of misconfiguration to find.
257            Ok(text) => toml::from_str::<Config>(&text).unwrap_or_else(|e| {
258                tracing::warn!(path, error = %e, "config file could not be parsed; using defaults");
259                Config::default()
260            }),
261            // A missing file is the documented, ordinary case: defaults apply.
262            Err(_) => Config::default(),
263        };
264        cfg.apply_env(|k| std::env::var(k).ok());
265        cfg
266    }
267
268    /// Load configuration from the conventional `churust.toml` path (plus
269    /// `CHURUST_*` env overrides). A shorthand for `Config::load("churust.toml")`;
270    /// this is what [`Churust::from_config`](crate::Churust::from_config) uses.
271    ///
272    /// ```no_run
273    /// use churust_core::Config;
274    /// let cfg = Config::load_default();
275    /// let _ = cfg.server.host;
276    /// ```
277    pub fn load_default() -> Self {
278        Self::load("churust.toml")
279    }
280
281    /// Overlay `CHURUST_*` environment variables onto this config, using the
282    /// provided `get` lookup so the source can be injected in tests.
283    ///
284    /// Recognized keys: `CHURUST_SERVER_HOST`, `CHURUST_SERVER_PORT`,
285    /// `CHURUST_SERVER_MAX_BODY_BYTES`, `CHURUST_SERVER_REQUEST_TIMEOUT_MS`.
286    /// Values that fail to parse (e.g. a non-numeric port) are ignored, leaving
287    /// the existing value in place.
288    ///
289    /// ```
290    /// use churust_core::Config;
291    /// use std::collections::HashMap;
292    ///
293    /// let env: HashMap<&str, &str> = [("CHURUST_SERVER_PORT", "7000")].into_iter().collect();
294    /// let mut cfg = Config::default();
295    /// cfg.apply_env(|k| env.get(k).map(|s| s.to_string()));
296    /// assert_eq!(cfg.server.port, 7000);
297    /// ```
298    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
299        if let Some(v) = get("CHURUST_SERVER_HOST") {
300            self.server.host = v;
301        }
302        if let Some(v) = get("CHURUST_SERVER_PORT").and_then(|s| s.parse().ok()) {
303            self.server.port = v;
304        }
305        if let Some(v) = get("CHURUST_SERVER_MAX_BODY_BYTES").and_then(|s| s.parse().ok()) {
306            self.server.max_body_bytes = v;
307        }
308        if let Some(v) = get("CHURUST_SERVER_HEADER_READ_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
309            self.server.header_read_timeout_ms = v;
310        }
311        if let Some(v) = get("CHURUST_SERVER_MAX_HEADERS").and_then(|s| s.parse().ok()) {
312            self.server.max_headers = v;
313        }
314        if let Some(v) = get("CHURUST_SERVER_MAX_PATH_SEGMENTS").and_then(|s| s.parse().ok()) {
315            self.server.max_path_segments = v;
316        }
317        if let Some(v) = get("CHURUST_SERVER_WS_MAX_FRAME_BYTES").and_then(|s| s.parse().ok()) {
318            self.server.ws_max_frame_bytes = v;
319        }
320        if let Some(v) = get("CHURUST_SERVER_KEEP_ALIVE_MS").and_then(|s| s.parse().ok()) {
321            self.server.keep_alive_ms = v;
322        }
323        if let Some(v) = get("CHURUST_SERVER_BACKLOG").and_then(|s| s.parse().ok()) {
324            self.server.backlog = v;
325        }
326        if let Some(v) = get("CHURUST_SERVER_SHUTDOWN_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
327            self.server.shutdown_timeout_ms = v;
328        }
329        if let Some(v) = get("CHURUST_SERVER_WS_MAX_MESSAGE_BYTES").and_then(|s| s.parse().ok()) {
330            self.server.ws_max_message_bytes = v;
331        }
332        if let Some(v) = get("CHURUST_SERVER_REQUEST_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
333            self.server.request_timeout_ms = v;
334        }
335        if let Some(v) = get("CHURUST_SERVER_PATH_POLICY") {
336            match v.to_ascii_lowercase().as_str() {
337                "strict" => self.server.path_policy = crate::path::PathPolicy::Strict,
338                "redirect" => self.server.path_policy = crate::path::PathPolicy::Redirect,
339                "collapse" => self.server.path_policy = crate::path::PathPolicy::Collapse,
340                // An unrecognised value keeps the safer setting rather than
341                // silently loosening it on a typo.
342                _ => {}
343            }
344        }
345        if let Some(v) = get("CHURUST_SERVER_H2_MAX_HEADER_LIST_SIZE").and_then(|s| s.parse().ok())
346        {
347            self.server.h2_max_header_list_size = v;
348        }
349        if let Some(v) =
350            get("CHURUST_SERVER_H2_MAX_CONCURRENT_STREAMS").and_then(|s| s.parse().ok())
351        {
352            self.server.h2_max_concurrent_streams = v;
353        }
354        if let Some(v) = get("CHURUST_SERVER_WS_IDLE_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
355            self.server.ws_idle_timeout_ms = v;
356        }
357        if let Some(v) = get("CHURUST_SERVER_MAX_CONNECTIONS").and_then(|s| s.parse().ok()) {
358            self.server.max_connections = v;
359        }
360        if let Some(v) = get("CHURUST_SERVER_MAX_TLS_HANDSHAKES").and_then(|s| s.parse().ok()) {
361            self.server.max_tls_handshakes = v;
362        }
363        if let Some(v) = get("CHURUST_SERVER_TLS_HANDSHAKE_TIMEOUT_MS").and_then(|s| s.parse().ok())
364        {
365            self.server.tls_handshake_timeout_ms = v;
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use std::collections::HashMap;
374
375    #[test]
376    fn defaults_are_sane() {
377        let c = Config::default();
378        assert_eq!(c.server.port, 8080);
379        assert_eq!(c.server.max_body_bytes, 1 << 20);
380        assert!(c.tls.is_none());
381    }
382
383    #[test]
384    fn parses_toml() {
385        let text = r#"
386            [server]
387            host = "0.0.0.0"
388            port = 9090
389        "#;
390        let c: Config = toml::from_str(text).unwrap();
391        assert_eq!(c.server.host, "0.0.0.0");
392        assert_eq!(c.server.port, 9090);
393        // unspecified fields fall back to defaults
394        assert_eq!(c.server.max_body_bytes, 1 << 20);
395    }
396
397    #[test]
398    fn env_overrides_file() {
399        let mut c = Config::default();
400        let env: HashMap<&str, &str> = [("CHURUST_SERVER_PORT", "7000")].into_iter().collect();
401        c.apply_env(|k| env.get(k).map(|s| s.to_string()));
402        assert_eq!(c.server.port, 7000);
403    }
404}