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}