Skip to main content

kerniq_core/
config.rs

1//! Server configuration.
2//!
3//! [`ServerConfig`] can be built programmatically, read from `KERNIQ_*`
4//! environment variables, or (with the `config-file` feature) loaded from a
5//! TOML file. Sources can be layered:
6//!
7//! ```no_run
8//! # #[cfg(feature = "config-file")]
9//! # fn demo() -> Result<(), kerniq_core::ConfigError> {
10//! use kerniq_core::ServerConfig;
11//!
12//! // Defaults, overridden by a file, overridden by the environment.
13//! let config = ServerConfig::from_file("kerniq.toml")?.apply_env()?;
14//! # Ok(()) }
15//! ```
16//!
17//! # What each setting controls
18//!
19//! The three concurrency-related settings are deliberately separate because
20//! they are enforced at different layers:
21//!
22//! | Setting | Enforced by | Meaning |
23//! |---|---|---|
24//! | `workers` | Tokio | Worker threads of the runtime. Only honoured by `run_blocking`, because with `#[tokio::main]` *you* own the runtime. |
25//! | `max_connections` | Accept loop | Open TCP connections. When reached, the server stops accepting until one closes. |
26//! | `max_concurrent_requests` | Tower middleware | Requests being handled at once, across all connections. Excess requests wait. |
27//!
28//! Every setting listed here is honoured by the server; there are no
29//! placeholder options.
30
31use std::time::Duration;
32
33use serde::{Deserialize, Serialize};
34
35/// Errors produced while loading or validating configuration.
36#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum ConfigError {
39    /// A value could not be parsed or is out of range.
40    #[error("invalid value {value:?} for `{key}`: {reason}")]
41    InvalidValue {
42        /// The setting or environment variable name.
43        key: String,
44        /// The offending value.
45        value: String,
46        /// Why it was rejected.
47        reason: String,
48    },
49    /// A configuration file could not be read.
50    #[error("failed to read configuration file `{path}`: {source}")]
51    Read {
52        /// The file path.
53        path: String,
54        /// The underlying I/O error.
55        #[source]
56        source: std::io::Error,
57    },
58    /// A configuration file could not be parsed.
59    #[error("failed to parse configuration file `{path}`: {message}")]
60    Parse {
61        /// The file path.
62        path: String,
63        /// The parser's message.
64        message: String,
65    },
66}
67
68/// Configuration for the HTTP server.
69///
70/// Fields are public for reading; the same-named builder methods consume and
71/// return the config for fluent construction:
72///
73/// ```
74/// use std::time::Duration;
75/// use kerniq_core::ServerConfig;
76///
77/// let config = ServerConfig::new()
78///     .host("0.0.0.0")
79///     .port(3000)
80///     .workers(4)
81///     .request_timeout(Duration::from_secs(30))
82///     .max_body_size(10 * 1024 * 1024);
83///
84/// assert_eq!(config.address(), "0.0.0.0:3000");
85/// assert_eq!(config.workers, Some(4));
86/// ```
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(default, deny_unknown_fields)]
89pub struct ServerConfig {
90    /// Interface to bind to. Default: `127.0.0.1`.
91    pub host: String,
92    /// Port to bind to. `0` selects an ephemeral port. Default: `3000`.
93    pub port: u16,
94    /// Tokio worker threads used by `run_blocking`. Default: one per CPU core.
95    pub workers: Option<usize>,
96    /// Maximum time to produce a response. Exceeding it yields
97    /// `408 REQUEST_TIMEOUT`. Default: no timeout.
98    #[serde(with = "secs::option")]
99    pub request_timeout: Option<Duration>,
100    /// Maximum request body size in bytes accepted by body extractors
101    /// (`Json`, `String`, `Bytes`, ...). Larger bodies yield
102    /// `413 PAYLOAD_TOO_LARGE`. Default: 2 MiB.
103    pub max_body_size: usize,
104    /// Maximum number of open TCP connections. Default: unlimited.
105    pub max_connections: Option<usize>,
106    /// Maximum number of requests processed concurrently. Default: unlimited.
107    pub max_concurrent_requests: Option<usize>,
108    /// How long graceful shutdown waits for in-flight requests and background
109    /// tasks before giving up. Default: 30 seconds.
110    #[serde(with = "secs")]
111    pub shutdown_timeout: Duration,
112    /// Maximum time for a client to send the request headers (protects
113    /// against slow-header attacks). `0` disables it, which removes a
114    /// per-request timer (a few percent of throughput on trivial endpoints)
115    /// at the cost of that protection. Default: 30 seconds.
116    #[serde(with = "secs")]
117    pub header_read_timeout: Duration,
118    /// Disable Nagle's algorithm on accepted sockets. Default: `true`.
119    pub tcp_nodelay: bool,
120    /// Make the peer address available to handlers through
121    /// `ConnectInfo<SocketAddr>`. Costs one small allocation per request.
122    /// Default: `true`.
123    pub connect_info: bool,
124}
125
126/// The default `max_body_size` (2 MiB, the same default as Axum).
127pub const DEFAULT_MAX_BODY_SIZE: usize = 2 * 1024 * 1024;
128
129impl Default for ServerConfig {
130    fn default() -> Self {
131        Self {
132            host: "127.0.0.1".to_owned(),
133            port: 3000,
134            workers: None,
135            request_timeout: None,
136            max_body_size: DEFAULT_MAX_BODY_SIZE,
137            max_connections: None,
138            max_concurrent_requests: None,
139            shutdown_timeout: Duration::from_secs(30),
140            header_read_timeout: Duration::from_secs(30),
141            tcp_nodelay: true,
142            connect_info: true,
143        }
144    }
145}
146
147impl ServerConfig {
148    /// Creates a configuration with default values.
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Sets the interface to bind to.
154    #[must_use]
155    pub fn host(mut self, host: impl Into<String>) -> Self {
156        self.host = host.into();
157        self
158    }
159
160    /// Sets the port to bind to.
161    #[must_use]
162    pub fn port(mut self, port: u16) -> Self {
163        self.port = port;
164        self
165    }
166
167    /// Sets the number of Tokio worker threads used by `run_blocking`.
168    #[must_use]
169    pub fn workers(mut self, workers: usize) -> Self {
170        self.workers = Some(workers);
171        self
172    }
173
174    /// Sets the per-request timeout.
175    #[must_use]
176    pub fn request_timeout(mut self, timeout: Duration) -> Self {
177        self.request_timeout = Some(timeout);
178        self
179    }
180
181    /// Sets the maximum request body size in bytes.
182    #[must_use]
183    pub fn max_body_size(mut self, bytes: usize) -> Self {
184        self.max_body_size = bytes;
185        self
186    }
187
188    /// Limits the number of open TCP connections.
189    #[must_use]
190    pub fn max_connections(mut self, max: usize) -> Self {
191        self.max_connections = Some(max);
192        self
193    }
194
195    /// Limits the number of requests processed concurrently.
196    #[must_use]
197    pub fn max_concurrent_requests(mut self, max: usize) -> Self {
198        self.max_concurrent_requests = Some(max);
199        self
200    }
201
202    /// Sets the graceful shutdown timeout.
203    #[must_use]
204    pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
205        self.shutdown_timeout = timeout;
206        self
207    }
208
209    /// Sets the request header read timeout (`Duration::ZERO` disables it).
210    #[must_use]
211    pub fn header_read_timeout(mut self, timeout: Duration) -> Self {
212        self.header_read_timeout = timeout;
213        self
214    }
215
216    /// Enables or disables `TCP_NODELAY`.
217    #[must_use]
218    pub fn tcp_nodelay(mut self, enabled: bool) -> Self {
219        self.tcp_nodelay = enabled;
220        self
221    }
222
223    /// Enables or disables `ConnectInfo<SocketAddr>` for handlers.
224    #[must_use]
225    pub fn connect_info(mut self, enabled: bool) -> Self {
226        self.connect_info = enabled;
227        self
228    }
229
230    /// The `host:port` address, with brackets around IPv6 hosts.
231    pub fn address(&self) -> String {
232        if self.host.contains(':') && !self.host.starts_with('[') {
233            format!("[{}]:{}", self.host, self.port)
234        } else {
235            format!("{}:{}", self.host, self.port)
236        }
237    }
238
239    /// Builds a configuration from defaults overridden by `KERNIQ_*`
240    /// environment variables. See [`ServerConfig::apply_env_vars`].
241    pub fn from_env() -> Result<Self, ConfigError> {
242        Self::default().apply_env()
243    }
244
245    /// Overrides values from the process environment. See
246    /// [`ServerConfig::apply_env_vars`].
247    pub fn apply_env(self) -> Result<Self, ConfigError> {
248        self.apply_env_vars(std::env::vars())
249    }
250
251    /// Overrides values from the given `(name, value)` pairs, using these
252    /// variables (durations are in seconds, sizes in bytes):
253    ///
254    /// | Variable | Setting |
255    /// |---|---|
256    /// | `KERNIQ_HOST` | `host` |
257    /// | `KERNIQ_PORT` | `port` |
258    /// | `KERNIQ_WORKERS` | `workers` |
259    /// | `KERNIQ_REQUEST_TIMEOUT` | `request_timeout` |
260    /// | `KERNIQ_MAX_BODY_SIZE` | `max_body_size` |
261    /// | `KERNIQ_MAX_CONNECTIONS` | `max_connections` |
262    /// | `KERNIQ_MAX_CONCURRENT_REQUESTS` | `max_concurrent_requests` |
263    /// | `KERNIQ_SHUTDOWN_TIMEOUT` | `shutdown_timeout` |
264    /// | `KERNIQ_HEADER_READ_TIMEOUT` | `header_read_timeout` |
265    /// | `KERNIQ_TCP_NODELAY` | `tcp_nodelay` |
266    /// | `KERNIQ_CONNECT_INFO` | `connect_info` |
267    ///
268    /// Unrelated variables are ignored. The result is validated.
269    pub fn apply_env_vars<I, K, V>(mut self, vars: I) -> Result<Self, ConfigError>
270    where
271        I: IntoIterator<Item = (K, V)>,
272        K: AsRef<str>,
273        V: AsRef<str>,
274    {
275        for (key, value) in vars {
276            let (key, value) = (key.as_ref(), value.as_ref().trim());
277            let Some(name) = key.strip_prefix("KERNIQ_") else {
278                continue;
279            };
280            match name {
281                "HOST" => self.host = value.to_owned(),
282                "PORT" => self.port = parse(key, value)?,
283                "WORKERS" => self.workers = Some(parse(key, value)?),
284                "REQUEST_TIMEOUT" => self.request_timeout = Some(parse_secs(key, value)?),
285                "MAX_BODY_SIZE" => self.max_body_size = parse(key, value)?,
286                "MAX_CONNECTIONS" => self.max_connections = Some(parse(key, value)?),
287                "MAX_CONCURRENT_REQUESTS" => {
288                    self.max_concurrent_requests = Some(parse(key, value)?)
289                }
290                "SHUTDOWN_TIMEOUT" => self.shutdown_timeout = parse_secs(key, value)?,
291                "HEADER_READ_TIMEOUT" => self.header_read_timeout = parse_secs(key, value)?,
292                "TCP_NODELAY" => self.tcp_nodelay = parse_bool(key, value)?,
293                "CONNECT_INFO" => self.connect_info = parse_bool(key, value)?,
294                _ => {}
295            }
296        }
297        self.validate()?;
298        Ok(self)
299    }
300
301    /// Loads a configuration from a TOML file. Missing keys use defaults;
302    /// unknown keys are rejected. Durations are given in seconds.
303    ///
304    /// ```toml
305    /// host = "0.0.0.0"
306    /// port = 8080
307    /// request_timeout = 30
308    /// max_concurrent_requests = 1024
309    /// ```
310    #[cfg(feature = "config-file")]
311    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, ConfigError> {
312        let path = path.as_ref();
313        let display = path.display().to_string();
314        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
315            path: display.clone(),
316            source,
317        })?;
318        Self::from_toml_str(&text).map_err(|err| match err {
319            ConfigError::Parse { message, .. } => ConfigError::Parse {
320                path: display,
321                message,
322            },
323            other => other,
324        })
325    }
326
327    /// Parses a configuration from a TOML string. See [`ServerConfig::from_file`].
328    #[cfg(feature = "config-file")]
329    pub fn from_toml_str(text: &str) -> Result<Self, ConfigError> {
330        let config: Self = toml::from_str(text).map_err(|err| ConfigError::Parse {
331            path: "<string>".to_owned(),
332            message: err.message().to_owned(),
333        })?;
334        config.validate()?;
335        Ok(config)
336    }
337
338    /// Checks that values are within range.
339    pub fn validate(&self) -> Result<(), ConfigError> {
340        fn non_zero(key: &str, value: Option<usize>) -> Result<(), ConfigError> {
341            match value {
342                Some(0) => Err(ConfigError::InvalidValue {
343                    key: key.to_owned(),
344                    value: "0".to_owned(),
345                    reason: "must be greater than zero".to_owned(),
346                }),
347                _ => Ok(()),
348            }
349        }
350        if self.host.trim().is_empty() {
351            return Err(ConfigError::InvalidValue {
352                key: "host".to_owned(),
353                value: self.host.clone(),
354                reason: "must not be empty".to_owned(),
355            });
356        }
357        non_zero("workers", self.workers)?;
358        non_zero("max_connections", self.max_connections)?;
359        non_zero("max_concurrent_requests", self.max_concurrent_requests)?;
360        if self.request_timeout == Some(Duration::ZERO) {
361            return Err(ConfigError::InvalidValue {
362                key: "request_timeout".to_owned(),
363                value: "0".to_owned(),
364                reason: "must be greater than zero".to_owned(),
365            });
366        }
367        Ok(())
368    }
369}
370
371fn parse<T>(key: &str, value: &str) -> Result<T, ConfigError>
372where
373    T: std::str::FromStr,
374    T::Err: std::fmt::Display,
375{
376    value
377        .parse()
378        .map_err(|err: T::Err| ConfigError::InvalidValue {
379            key: key.to_owned(),
380            value: value.to_owned(),
381            reason: err.to_string(),
382        })
383}
384
385fn parse_secs(key: &str, value: &str) -> Result<Duration, ConfigError> {
386    let secs: f64 = parse(key, value)?;
387    Duration::try_from_secs_f64(secs).map_err(|err| ConfigError::InvalidValue {
388        key: key.to_owned(),
389        value: value.to_owned(),
390        reason: err.to_string(),
391    })
392}
393
394fn parse_bool(key: &str, value: &str) -> Result<bool, ConfigError> {
395    match value.to_ascii_lowercase().as_str() {
396        "1" | "true" | "yes" | "on" => Ok(true),
397        "0" | "false" | "no" | "off" => Ok(false),
398        _ => Err(ConfigError::InvalidValue {
399            key: key.to_owned(),
400            value: value.to_owned(),
401            reason: "expected a boolean (true/false/1/0)".to_owned(),
402        }),
403    }
404}
405
406/// Serde helpers representing durations as (fractional) seconds.
407mod secs {
408    use std::time::Duration;
409
410    use serde::{Deserialize, Deserializer, Serializer};
411
412    pub(super) fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
413        s.serialize_f64(d.as_secs_f64())
414    }
415
416    pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
417        let secs = f64::deserialize(d)?;
418        Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)
419    }
420
421    pub(super) mod option {
422        use super::*;
423
424        pub(crate) fn serialize<S: Serializer>(
425            d: &Option<Duration>,
426            s: S,
427        ) -> Result<S::Ok, S::Error> {
428            match d {
429                Some(d) => s.serialize_some(&d.as_secs_f64()),
430                None => s.serialize_none(),
431            }
432        }
433
434        pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
435            d: D,
436        ) -> Result<Option<Duration>, D::Error> {
437            Option::<f64>::deserialize(d)?
438                .map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom))
439                .transpose()
440        }
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn builder_sets_fields() {
450        let c = ServerConfig::new()
451            .host("0.0.0.0")
452            .port(8080)
453            .workers(4)
454            .request_timeout(Duration::from_secs(30))
455            .max_body_size(1024)
456            .max_connections(10)
457            .max_concurrent_requests(5)
458            .shutdown_timeout(Duration::from_secs(5));
459        assert_eq!(c.address(), "0.0.0.0:8080");
460        assert_eq!(c.workers, Some(4));
461        assert_eq!(c.request_timeout, Some(Duration::from_secs(30)));
462        assert_eq!(c.max_body_size, 1024);
463        assert_eq!(c.max_connections, Some(10));
464        assert_eq!(c.max_concurrent_requests, Some(5));
465        assert_eq!(c.shutdown_timeout, Duration::from_secs(5));
466        c.validate().unwrap();
467    }
468
469    #[test]
470    fn ipv6_address_is_bracketed() {
471        assert_eq!(
472            ServerConfig::new().host("::1").port(80).address(),
473            "[::1]:80"
474        );
475    }
476
477    #[test]
478    fn env_overrides() {
479        let c = ServerConfig::default()
480            .apply_env_vars([
481                ("KERNIQ_HOST", "0.0.0.0"),
482                ("KERNIQ_PORT", "9000"),
483                ("KERNIQ_REQUEST_TIMEOUT", "2.5"),
484                ("KERNIQ_TCP_NODELAY", "false"),
485                ("KERNIQ_MAX_CONCURRENT_REQUESTS", "64"),
486                ("PATH", "/usr/bin"),
487                ("KERNIQ_UNKNOWN", "ignored"),
488            ])
489            .unwrap();
490        assert_eq!(c.address(), "0.0.0.0:9000");
491        assert_eq!(c.request_timeout, Some(Duration::from_millis(2500)));
492        assert!(!c.tcp_nodelay);
493        assert_eq!(c.max_concurrent_requests, Some(64));
494    }
495
496    #[test]
497    fn env_rejects_bad_values() {
498        let err = ServerConfig::default()
499            .apply_env_vars([("KERNIQ_PORT", "seventy")])
500            .unwrap_err();
501        assert!(err.to_string().contains("KERNIQ_PORT"));
502        let err = ServerConfig::default()
503            .apply_env_vars([("KERNIQ_WORKERS", "0")])
504            .unwrap_err();
505        assert!(err.to_string().contains("workers"));
506    }
507
508    #[cfg(feature = "config-file")]
509    #[test]
510    fn toml_parsing() {
511        let c = ServerConfig::from_toml_str(
512            "host = \"0.0.0.0\"\nport = 8080\nrequest_timeout = 30\nmax_connections = 100\n",
513        )
514        .unwrap();
515        assert_eq!(c.port, 8080);
516        assert_eq!(c.request_timeout, Some(Duration::from_secs(30)));
517        assert_eq!(c.max_connections, Some(100));
518        assert_eq!(c.shutdown_timeout, Duration::from_secs(30));
519
520        let err = ServerConfig::from_toml_str("prot = 1").unwrap_err();
521        assert!(matches!(err, ConfigError::Parse { .. }));
522    }
523}