churust-core 0.3.2

Core engine, routing, pipeline, and extractors for the Churust web framework.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! Layered configuration: defaults < `churust.toml` < env (`CHURUST_*`) < code.

use serde::Deserialize;

/// The fully-resolved application configuration.
///
/// Deserializes from a `churust.toml` file (with a `[server]` table and an
/// optional `[tls]` table) and can be overlaid with `CHURUST_*` environment
/// variables via [`apply_env`](Config::apply_env). Apply it to a builder with
/// [`AppBuilder::with_config`](crate::AppBuilder::with_config), or use
/// [`Churust::from_config`](crate::Churust::from_config) to load and apply the
/// defaults in one step. Unspecified fields fall back to sane defaults.
///
/// ```
/// use churust_core::Config;
///
/// let cfg: Config = toml::from_str(r#"
///     [server]
///     host = "0.0.0.0"
///     port = 9090
/// "#).unwrap();
/// assert_eq!(cfg.server.host, "0.0.0.0");
/// assert_eq!(cfg.server.port, 9090);
/// // unspecified fields keep their defaults
/// assert_eq!(cfg.server.max_body_bytes, 1 << 20);
/// assert!(cfg.tls.is_none());
/// ```
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
    /// Server-level settings (`[server]` table).
    pub server: ServerSection,
    /// Optional TLS settings (`[tls]` table); `None` serves plaintext HTTP.
    pub tls: Option<TlsSection>,
}

/// The `[server]` configuration table.
///
/// Every field has a default (see [`ServerSection::default`]), so a partial
/// `[server]` table is valid.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerSection {
    /// Bind address host (default `"127.0.0.1"`).
    pub host: String,
    /// Bind port (default `8080`).
    pub port: u16,
    /// Maximum accepted request body size in bytes (default `1 MiB`). Larger
    /// bodies are rejected with `413 Payload Too Large`.
    ///
    /// A body whose `Content-Length` exceeds this is refused before the request
    /// is dispatched, so a handler that never reads the body cannot serve one
    /// anyway. A chunked body declares no length, so it is bounded as it is
    /// read — which means a handler that ignores a chunked body will not notice
    /// the cap. Reject on `Transfer-Encoding` explicitly if that matters.
    ///
    /// Raise [`request_timeout_ms`](Self::request_timeout_ms) alongside it: the
    /// per-request timeout spans the upload, so a generous size cap paired with
    /// a short timeout rejects large-but-legitimate transfers part-way through.
    pub max_body_bytes: usize,
    /// Per-request timeout in milliseconds (default `30000`). `0` disables the
    /// timeout.
    ///
    /// **It bounds the whole exchange, including reading the request body.**
    /// Bodies stream, so they are read inside the handler rather than before
    /// it, and a slow upload consumes this budget however small it is. Raising
    /// [`max_body_bytes`](Self::max_body_bytes) therefore means raising this in
    /// step: a 100 MiB cap with a 30-second timeout rejects any client slower
    /// than ~3.4 MiB/s, mid-transfer, with `408`.
    pub request_timeout_ms: u64,
    /// How long a connection may take to send its complete header block, in
    /// milliseconds (default `10000`). `0` disables it.
    ///
    /// This is the slow-loris defence: without it a client can hold a
    /// connection open indefinitely by dribbling one header byte at a time,
    /// because the per-request timeout does not start until there is a request.
    ///
    /// It covers all three phases of a connection, by different means.
    ///
    /// Before either protocol exists, the server is still reading up to the 24
    /// bytes of the HTTP/2 preface to find out which one to speak, and neither
    /// mechanism below has been created yet. A connection that does not get as
    /// far as choosing a protocol within this deadline is closed — which is
    /// what bounds a peer that connects and then sends nothing at all.
    ///
    /// HTTP/1 then gets a literal deadline on the header block. HTTP/2 has no
    /// equivalent — a header block arrives as frames on an already-open
    /// connection — so the same value drives a keep-alive PING and the deadline
    /// for its acknowledgement, which answers the equivalent question of
    /// whether the peer is still there. A stalled h2 peer is therefore dropped
    /// within roughly twice this value, and a live one with nothing to say
    /// answers the ping and stays: once a protocol has been negotiated this
    /// deadline no longer applies, so an idle-but-responsive client is not
    /// bound by it.
    pub header_read_timeout_ms: u64,
    /// Maximum number of headers accepted on a request (default `100`).
    pub max_headers: usize,
    /// Maximum path segments accepted before a request is rejected with `414`
    /// (default `64`).
    ///
    /// The router walks segments recursively with backtracking, so path depth
    /// is a stack-depth question. hyper bounds the request line, which bounds
    /// this in practice, but the bound should be Churust's own and stated
    /// rather than inherited by accident.
    pub max_path_segments: usize,
    /// Maximum WebSocket frame size in bytes (default `1 MiB`). Requires the
    /// `ws` feature.
    pub ws_max_frame_bytes: usize,
    /// How long an idle connection is kept open for reuse, in milliseconds
    /// (default `75000`). `0` disables keep-alive: answer and close.
    ///
    /// Idle means *no request in flight* — a handler slower than this is busy,
    /// not idle, and its connection is left alone. The timer restarts when a
    /// request finishes.
    pub keep_alive_ms: u64,
    /// Listen backlog: connections the kernel may queue before the accept loop
    /// reaches them (default `1024`).
    pub backlog: u32,
    /// How long graceful shutdown waits for in-flight requests to finish, in
    /// milliseconds (default `30000`). `0` waits indefinitely.
    ///
    /// Without a bound, one slow request delays shutdown forever, which in a
    /// container means being killed rather than exiting cleanly.
    pub shutdown_timeout_ms: u64,
    /// Maximum reassembled WebSocket message size in bytes (default `4 MiB`).
    /// Requires the `ws` feature.
    ///
    /// Separate from the frame cap because a peer can send many small
    /// continuation frames that reassemble into one enormous message.
    pub ws_max_message_bytes: usize,
    /// What to do with a non-canonical path spelling (default `"strict"`).
    ///
    /// One of `"strict"`, `"redirect"` or `"collapse"`. See
    /// [`PathPolicy`](crate::PathPolicy).
    pub path_policy: crate::path::PathPolicy,
    /// Maximum size of a received HTTP/2 header block, in bytes (default
    /// `16384`).
    ///
    /// The HTTP/2 counterpart of [`max_headers`](Self::max_headers), which
    /// configures HTTP/1 only: h2 has no header *count*, it has an encoded
    /// size. Exceeding it is refused at the protocol level.
    pub h2_max_header_list_size: u32,
    /// Maximum concurrent HTTP/2 streams per connection (default `200`). `0`
    /// removes the limit.
    ///
    /// One h2 connection multiplexes many requests, so without this a single
    /// connection is an unbounded amount of concurrent work — the shape behind
    /// the HTTP/2 stream-flood denial-of-service family. hyper's own docs
    /// encourage setting an explicit limit rather than inheriting its default.
    pub h2_max_concurrent_streams: u32,
    /// How long an upgraded WebSocket may sit with no traffic in either
    /// direction before it is closed, in milliseconds (default `300000`, five
    /// minutes). `0` disables the bound. Requires the `ws` feature.
    ///
    /// An upgraded socket holds a connection permit for its whole life, and
    /// none of the HTTP-level bounds survive the upgrade —
    /// [`header_read_timeout_ms`](Self::header_read_timeout_ms) applies before
    /// there is a WebSocket and [`request_timeout_ms`](Self::request_timeout_ms)
    /// wraps a request that has already completed. Without this, a peer that
    /// completes the handshake and then says nothing pins a permit until the
    /// process restarts.
    pub ws_idle_timeout_ms: u64,
    /// Maximum simultaneously served connections (default `25000`). `0` means
    /// unlimited.
    ///
    /// The backlog bounds what the kernel queues; this bounds what the process
    /// accepts. Without it, memory and file descriptors are limited only by
    /// what the OS will hand out, and the failure mode is the whole process
    /// dying rather than new connections waiting.
    pub max_connections: usize,
    /// Maximum TLS handshakes in progress at once (default `256`). `0` means
    /// unlimited. Requires the `tls` feature.
    ///
    /// Much smaller than [`max_connections`](Self::max_connections) on purpose:
    /// a handshake is asymmetric work — cheap to ask for, expensive to answer —
    /// so it needs its own, tighter bound.
    pub max_tls_handshakes: usize,
    /// How long a TLS handshake may take before the connection is dropped, in
    /// milliseconds (default `10000`). `0` disables the bound.
    ///
    /// [`header_read_timeout_ms`](Self::header_read_timeout_ms) cannot cover
    /// this: before the handshake completes there is no HTTP layer to time out.
    /// Without it, a client that completes the TCP handshake and then sends one
    /// byte per minute holds a connection open indefinitely.
    ///
    /// The clock starts when the connection is accepted, so it covers waiting
    /// for a [`max_tls_handshakes`](Self::max_tls_handshakes) permit as well as
    /// the handshake itself. That is what makes it a bound on how long a peer
    /// can hold a *connection* permit: timing only the handshake would let a
    /// queue of stalled peers expire a permit's worth at a time while the rest
    /// waited unbounded. A consequence worth knowing is that under genuine
    /// handshake overload a legitimate client can be dropped while still
    /// queued — the alternative is holding its connection slot instead.
    pub tls_handshake_timeout_ms: u64,
}

/// The `[tls]` configuration table: paths to a PEM certificate chain and
/// private key.
///
/// Only meaningful when the `tls` feature is enabled; otherwise the paths are
/// ignored. Set it on a builder with [`AppBuilder::tls`](crate::AppBuilder::tls).
#[derive(Debug, Clone, Deserialize)]
pub struct TlsSection {
    /// Path to the PEM-encoded certificate chain file.
    pub cert: String,
    /// Path to the PEM-encoded private key file.
    pub key: String,
}

impl Default for ServerSection {
    fn default() -> Self {
        Self {
            host: "127.0.0.1".into(),
            port: 8080,
            max_body_bytes: 1 << 20,
            request_timeout_ms: 30_000,
            header_read_timeout_ms: 10_000,
            max_headers: 100,
            max_path_segments: 64,
            ws_max_frame_bytes: 1 << 20,
            ws_max_message_bytes: 4 << 20,
            keep_alive_ms: 75_000,
            backlog: 1024,
            shutdown_timeout_ms: 30_000,
            path_policy: crate::path::PathPolicy::Strict,
            h2_max_header_list_size: 16 << 10,
            h2_max_concurrent_streams: 200,
            ws_idle_timeout_ms: 300_000,
            max_connections: 25_000,
            max_tls_handshakes: 256,
            tls_handshake_timeout_ms: 10_000,
        }
    }
}

impl Config {
    /// Load configuration by layering, in increasing precedence: built-in
    /// defaults, then the TOML file at `path` (if it exists and parses), then
    /// `CHURUST_*` environment variables.
    ///
    /// A missing or unparseable file is treated as "use defaults" rather than an
    /// error, so calling this without a config file present is safe.
    ///
    /// ```no_run
    /// use churust_core::Config;
    /// // Reads ./my-app.toml if present, then applies CHURUST_* env overrides.
    /// let cfg = Config::load("my-app.toml");
    /// let _ = cfg.server.port;
    /// ```
    pub fn load(path: &str) -> Self {
        let mut cfg = match std::fs::read_to_string(path) {
            // A malformed file still falls back to defaults — but says so.
            // Silently substituting defaults discards the operator's host,
            // port, limits and TLS paths over a single typo, and a server that
            // came up on 127.0.0.1:8080 with no TLS because of an unreported
            // parse error is the hardest kind of misconfiguration to find.
            Ok(text) => toml::from_str::<Config>(&text).unwrap_or_else(|e| {
                tracing::warn!(path, error = %e, "config file could not be parsed; using defaults");
                Config::default()
            }),
            // A missing file is the documented, ordinary case: defaults apply.
            Err(_) => Config::default(),
        };
        cfg.apply_env(|k| std::env::var(k).ok());
        cfg
    }

    /// Load configuration from the conventional `churust.toml` path (plus
    /// `CHURUST_*` env overrides). A shorthand for `Config::load("churust.toml")`;
    /// this is what [`Churust::from_config`](crate::Churust::from_config) uses.
    ///
    /// ```no_run
    /// use churust_core::Config;
    /// let cfg = Config::load_default();
    /// let _ = cfg.server.host;
    /// ```
    pub fn load_default() -> Self {
        Self::load("churust.toml")
    }

    /// Overlay `CHURUST_*` environment variables onto this config, using the
    /// provided `get` lookup so the source can be injected in tests.
    ///
    /// Recognized keys: `CHURUST_SERVER_HOST`, `CHURUST_SERVER_PORT`,
    /// `CHURUST_SERVER_MAX_BODY_BYTES`, `CHURUST_SERVER_REQUEST_TIMEOUT_MS`.
    /// Values that fail to parse (e.g. a non-numeric port) are ignored, leaving
    /// the existing value in place.
    ///
    /// ```
    /// use churust_core::Config;
    /// use std::collections::HashMap;
    ///
    /// let env: HashMap<&str, &str> = [("CHURUST_SERVER_PORT", "7000")].into_iter().collect();
    /// let mut cfg = Config::default();
    /// cfg.apply_env(|k| env.get(k).map(|s| s.to_string()));
    /// assert_eq!(cfg.server.port, 7000);
    /// ```
    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
        if let Some(v) = get("CHURUST_SERVER_HOST") {
            self.server.host = v;
        }
        if let Some(v) = get("CHURUST_SERVER_PORT").and_then(|s| s.parse().ok()) {
            self.server.port = v;
        }
        if let Some(v) = get("CHURUST_SERVER_MAX_BODY_BYTES").and_then(|s| s.parse().ok()) {
            self.server.max_body_bytes = v;
        }
        if let Some(v) = get("CHURUST_SERVER_HEADER_READ_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
            self.server.header_read_timeout_ms = v;
        }
        if let Some(v) = get("CHURUST_SERVER_MAX_HEADERS").and_then(|s| s.parse().ok()) {
            self.server.max_headers = v;
        }
        if let Some(v) = get("CHURUST_SERVER_MAX_PATH_SEGMENTS").and_then(|s| s.parse().ok()) {
            self.server.max_path_segments = v;
        }
        if let Some(v) = get("CHURUST_SERVER_WS_MAX_FRAME_BYTES").and_then(|s| s.parse().ok()) {
            self.server.ws_max_frame_bytes = v;
        }
        if let Some(v) = get("CHURUST_SERVER_KEEP_ALIVE_MS").and_then(|s| s.parse().ok()) {
            self.server.keep_alive_ms = v;
        }
        if let Some(v) = get("CHURUST_SERVER_BACKLOG").and_then(|s| s.parse().ok()) {
            self.server.backlog = v;
        }
        if let Some(v) = get("CHURUST_SERVER_SHUTDOWN_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
            self.server.shutdown_timeout_ms = v;
        }
        if let Some(v) = get("CHURUST_SERVER_WS_MAX_MESSAGE_BYTES").and_then(|s| s.parse().ok()) {
            self.server.ws_max_message_bytes = v;
        }
        if let Some(v) = get("CHURUST_SERVER_REQUEST_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
            self.server.request_timeout_ms = v;
        }
        if let Some(v) = get("CHURUST_SERVER_PATH_POLICY") {
            match v.to_ascii_lowercase().as_str() {
                "strict" => self.server.path_policy = crate::path::PathPolicy::Strict,
                "redirect" => self.server.path_policy = crate::path::PathPolicy::Redirect,
                "collapse" => self.server.path_policy = crate::path::PathPolicy::Collapse,
                // An unrecognised value keeps the safer setting rather than
                // silently loosening it on a typo.
                _ => {}
            }
        }
        if let Some(v) = get("CHURUST_SERVER_H2_MAX_HEADER_LIST_SIZE").and_then(|s| s.parse().ok())
        {
            self.server.h2_max_header_list_size = v;
        }
        if let Some(v) =
            get("CHURUST_SERVER_H2_MAX_CONCURRENT_STREAMS").and_then(|s| s.parse().ok())
        {
            self.server.h2_max_concurrent_streams = v;
        }
        if let Some(v) = get("CHURUST_SERVER_WS_IDLE_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
            self.server.ws_idle_timeout_ms = v;
        }
        if let Some(v) = get("CHURUST_SERVER_MAX_CONNECTIONS").and_then(|s| s.parse().ok()) {
            self.server.max_connections = v;
        }
        if let Some(v) = get("CHURUST_SERVER_MAX_TLS_HANDSHAKES").and_then(|s| s.parse().ok()) {
            self.server.max_tls_handshakes = v;
        }
        if let Some(v) = get("CHURUST_SERVER_TLS_HANDSHAKE_TIMEOUT_MS").and_then(|s| s.parse().ok())
        {
            self.server.tls_handshake_timeout_ms = v;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    #[test]
    fn defaults_are_sane() {
        let c = Config::default();
        assert_eq!(c.server.port, 8080);
        assert_eq!(c.server.max_body_bytes, 1 << 20);
        assert!(c.tls.is_none());
    }

    #[test]
    fn parses_toml() {
        let text = r#"
            [server]
            host = "0.0.0.0"
            port = 9090
        "#;
        let c: Config = toml::from_str(text).unwrap();
        assert_eq!(c.server.host, "0.0.0.0");
        assert_eq!(c.server.port, 9090);
        // unspecified fields fall back to defaults
        assert_eq!(c.server.max_body_bytes, 1 << 20);
    }

    #[test]
    fn env_overrides_file() {
        let mut c = Config::default();
        let env: HashMap<&str, &str> = [("CHURUST_SERVER_PORT", "7000")].into_iter().collect();
        c.apply_env(|k| env.get(k).map(|s| s.to_string()));
        assert_eq!(c.server.port, 7000);
    }
}