rust_web_server/server_config/mod.rs
1//! Typed server configuration.
2//!
3//! [`ServerConfig`] holds all per-instance configuration fields as typed Rust
4//! values. It replaces point-of-use `env::var("RWS_CONFIG_*")` calls with a
5//! struct that can be:
6//!
7//! - Built from environment variables once at startup: [`ServerConfig::from_env()`]
8//! - Constructed directly in tests without touching the environment:
9//! [`ServerConfig::default()`] / struct update syntax
10//! - Passed to [`App::with_config`] to create a fully isolated application
11//! instance — essential for parallel tests and embedded multi-tenant use
12//!
13//! # Test isolation example
14//!
15//! ```rust,ignore
16//! use rust_web_server::app::App;
17//! use rust_web_server::server_config::ServerConfig;
18//! use rust_web_server::test_client::TestClient;
19//!
20//! // No env writes, no lock needed.
21//! let app = App::with_config(ServerConfig {
22//! cors_allow_all: false,
23//! cors_allow_origins: "https://example.com".to_string(),
24//! ..ServerConfig::default()
25//! });
26//! let client = TestClient::new(app);
27//! let res = client.get("/").send();
28//! ```
29
30#[cfg(test)]
31mod tests;
32
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::{Arc, RwLock};
35
36use crate::entry_point::Config;
37
38/// Bumped whenever `RWS_CONFIG_*` variables change (see [`invalidate`]).
39static GENERATION: AtomicU64 = AtomicU64::new(1);
40
41/// The snapshot [`ServerConfig::current`] hands out, with the generation it
42/// was built at.
43static CURRENT: RwLock<Option<(u64, Arc<ServerConfig>)>> = RwLock::new(None);
44
45/// Mark the cached [`ServerConfig::current`] snapshot stale: the next call
46/// re-reads the environment. Called by everything in the crate that writes
47/// `RWS_CONFIG_*` variables (config file, command line, defaults,
48/// `config_reload::reload`); call it yourself after `std::env::set_var` of
49/// a setting that should take effect immediately.
50pub fn invalidate() {
51 GENERATION.fetch_add(1, Ordering::SeqCst);
52}
53
54/// Default `Content-Security-Policy` header value. Mirrors
55/// `Header::_CONTENT_SECURITY_POLICY_VALUE_DEFAULT` without creating a
56/// circular import (`server_config` ← `header` ← `cors` ← `server_config`).
57const CSP_DEFAULT: &str = "default-src 'self'";
58/// Default `Cache-Control` for static files: cache, but revalidate.
59pub(crate) const STATIC_CACHE_CONTROL_DEFAULT: &str = "no-cache";
60
61/// All runtime-configurable settings for one server instance.
62///
63/// Fields map 1-to-1 to `RWS_CONFIG_*` environment variable names documented
64/// in [`Config`]. Default values match the environment-variable defaults.
65///
66/// Construct via [`ServerConfig::from_env()`] at startup or
67/// [`ServerConfig::default()`] in tests.
68#[derive(Clone, Debug, PartialEq)]
69pub struct ServerConfig {
70 // ── CORS ─────────────────────────────────────────────────────────────────
71 /// `RWS_CONFIG_CORS_ALLOW_ALL` — when `true`, all cross-origin requests are
72 /// reflected back as allowed (echo the `Origin` header). Overrides all
73 /// other CORS fields. Default: `true`.
74 pub cors_allow_all: bool,
75 /// `RWS_CONFIG_CORS_ALLOW_ORIGINS` — comma-separated list of allowed
76 /// origins when `cors_allow_all` is `false`. Default: `""` (none).
77 pub cors_allow_origins: String,
78 /// `RWS_CONFIG_CORS_ALLOW_CREDENTIALS` — value for the
79 /// `Access-Control-Allow-Credentials` response header. Default: `""`.
80 pub cors_allow_credentials: String,
81 /// `RWS_CONFIG_CORS_ALLOW_METHODS` — value for
82 /// `Access-Control-Allow-Methods`. Default: `""`.
83 pub cors_allow_methods: String,
84 /// `RWS_CONFIG_CORS_ALLOW_HEADERS` — value for
85 /// `Access-Control-Allow-Headers`. Default: `""`.
86 pub cors_allow_headers: String,
87 /// `RWS_CONFIG_CORS_EXPOSE_HEADERS` — value for
88 /// `Access-Control-Expose-Headers`. Default: `""`.
89 pub cors_expose_headers: String,
90 /// `RWS_CONFIG_CORS_MAX_AGE` — value for `Access-Control-Max-Age`.
91 /// Default: `"86400"`.
92 pub cors_max_age: String,
93
94 // ── Security headers ──────────────────────────────────────────────────────
95 /// `RWS_CONFIG_CSP` — `Content-Security-Policy` header value. An empty
96 /// string suppresses the header. Unset: `default-src 'self'` when
97 /// [`security_headers`](Self::security_headers) is on, otherwise no header
98 /// (18.0.0; was always `default-src 'self'`).
99 pub csp: String,
100 /// `RWS_CONFIG_SECURITY_HEADERS` — send `X-Frame-Options: SAMEORIGIN`,
101 /// `Referrer-Policy` and `Permissions-Policy` on every response (and the
102 /// default CSP, unless `RWS_CONFIG_CSP` is set). Off by default (18.0.0;
103 /// was always on): nginx sends none of them, and over HTTP/2 every header
104 /// costs HPACK work. `X-Content-Type-Options: nosniff` is always sent.
105 pub security_headers: bool,
106 /// `RWS_CONFIG_NANOS_HEADERS` — send `Date-Unix-Epoch-Nanos` and, for
107 /// static files, `Last-Modified-Unix-Epoch-Nanos`. Off by default (18.0.0;
108 /// was always on): they change on every response, so HTTP/2 can't index
109 /// them, and `Date`/`Last-Modified` carry the same information to the second.
110 pub nanos_headers: bool,
111 /// `RWS_CONFIG_CLIENT_HINTS` — send `Accept-CH`, `Critical-CH` and the
112 /// client hints in `Vary` on every response. Off by default (18.0.0; was
113 /// always on): ~700 bytes per response, and `Critical-CH` makes Chromium
114 /// retry a request that came without the hints.
115 pub client_hints: bool,
116 /// `RWS_CONFIG_STATIC_CACHE_CONTROL` — `Cache-Control` for static files
117 /// (other responses keep `no-store, …`). Default `"no-cache"` (18.0.0; was
118 /// `no-store, …`): browsers keep the file and revalidate it with
119 /// `If-None-Match` / `If-Modified-Since` (a 304). An empty value keeps
120 /// the `no-store` default for static files too.
121 pub static_cache_control: String,
122 /// `RWS_CONFIG_EARLY_HINTS_HTTP1` — also send `103 Early Hints` to
123 /// HTTP/1.1 clients. Default `false`: browsers use them only over HTTP/2
124 /// and HTTP/3, and some old HTTP/1.1 clients mishandle extra `1xx`
125 /// responses (18.12.0).
126 pub early_hints_http1: bool,
127 /// `RWS_CONFIG_COMPRESSION_DICTIONARIES` — URL patterns (`/js/app.*.js`,
128 /// comma-separated) of static files used as compression dictionaries:
129 /// later versions go out as `dcz` / `dcb` deltas against the one the
130 /// browser has (18.12.0). Empty by default.
131 pub compression_dictionaries: Vec<String>,
132 /// `RWS_CONFIG_DIRECTORY_LISTING` — list a directory that has no
133 /// `index.html`. Default `true`; `false` answers 404 instead (18.8.4).
134 pub directory_listing: bool,
135
136 // ── Server internals ──────────────────────────────────────────────────────
137 /// `RWS_CONFIG_LOG_FORMAT` — `"json"` or `"combined"`. Default: `"json"`.
138 pub log_format: String,
139 /// `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` — bytes allocated for
140 /// incoming request parsing. Default: `10000`.
141 pub request_allocation_size: i64,
142}
143
144impl Default for ServerConfig {
145 fn default() -> Self {
146 Self {
147 cors_allow_all: Config::RWS_CONFIG_CORS_ALLOW_ALL_DEFAULT_VALUE
148 .eq_ignore_ascii_case("true"),
149 cors_allow_origins: Config::RWS_CONFIG_CORS_ALLOW_ORIGINS_DEFAULT_VALUE.to_string(),
150 cors_allow_credentials: Config::RWS_CONFIG_CORS_ALLOW_CREDENTIALS_DEFAULT_VALUE
151 .to_string(),
152 cors_allow_methods: Config::RWS_CONFIG_CORS_ALLOW_METHODS_DEFAULT_VALUE.to_string(),
153 cors_allow_headers: Config::RWS_CONFIG_CORS_ALLOW_HEADERS_DEFAULT_VALUE.to_string(),
154 cors_expose_headers: Config::RWS_CONFIG_CORS_EXPOSE_HEADERS_DEFAULT_VALUE.to_string(),
155 cors_max_age: Config::RWS_CONFIG_CORS_MAX_AGE_DEFAULT_VALUE.to_string(),
156 csp: String::new(),
157 security_headers: false,
158 nanos_headers: false,
159 client_hints: false,
160 static_cache_control: STATIC_CACHE_CONTROL_DEFAULT.to_string(),
161 early_hints_http1: false,
162 compression_dictionaries: Vec::new(),
163 directory_listing: true,
164 log_format: Config::RWS_CONFIG_LOG_FORMAT_DEFAULT_VALUE.to_string(),
165 request_allocation_size: *Config::RWS_DEFAULT_REQUEST_ALLOCATION_SIZE_IN_BYTES,
166 }
167 }
168}
169
170impl ServerConfig {
171 /// Build a `ServerConfig` by reading all `RWS_CONFIG_*` environment
172 /// variables. Missing variables fall back to their default values.
173 ///
174 /// Each call reads every variable — the environment lock is taken once
175 /// per variable — so per-request code uses [`ServerConfig::current`].
176 pub fn from_env() -> Self {
177 let read = |key: &str| std::env::var(key).unwrap_or_default();
178 Self {
179 cors_allow_all: read(Config::RWS_CONFIG_CORS_ALLOW_ALL)
180 .eq_ignore_ascii_case("true"),
181 cors_allow_origins: read(Config::RWS_CONFIG_CORS_ALLOW_ORIGINS),
182 cors_allow_credentials: read(Config::RWS_CONFIG_CORS_ALLOW_CREDENTIALS),
183 cors_allow_methods: read(Config::RWS_CONFIG_CORS_ALLOW_METHODS),
184 cors_allow_headers: read(Config::RWS_CONFIG_CORS_ALLOW_HEADERS),
185 cors_expose_headers: read(Config::RWS_CONFIG_CORS_EXPOSE_HEADERS),
186 cors_max_age: read(Config::RWS_CONFIG_CORS_MAX_AGE),
187 csp: std::env::var("RWS_CONFIG_CSP").unwrap_or_else(|_| {
188 if read("RWS_CONFIG_SECURITY_HEADERS").eq_ignore_ascii_case("true") {
189 CSP_DEFAULT.to_string()
190 } else {
191 String::new()
192 }
193 }),
194 security_headers: read("RWS_CONFIG_SECURITY_HEADERS").eq_ignore_ascii_case("true"),
195 nanos_headers: read("RWS_CONFIG_NANOS_HEADERS").eq_ignore_ascii_case("true"),
196 client_hints: read("RWS_CONFIG_CLIENT_HINTS").eq_ignore_ascii_case("true"),
197 static_cache_control: std::env::var("RWS_CONFIG_STATIC_CACHE_CONTROL")
198 .unwrap_or_else(|_| STATIC_CACHE_CONTROL_DEFAULT.to_string()),
199 early_hints_http1: read("RWS_CONFIG_EARLY_HINTS_HTTP1").eq_ignore_ascii_case("true"),
200 compression_dictionaries: read("RWS_CONFIG_COMPRESSION_DICTIONARIES")
201 .split(',')
202 .map(|p| p.trim().to_string())
203 .filter(|p| p.starts_with('/') && crate::early_hints::valid(p))
204 .collect(),
205 directory_listing: !read("RWS_CONFIG_DIRECTORY_LISTING").eq_ignore_ascii_case("false"),
206 log_format: read(Config::RWS_CONFIG_LOG_FORMAT),
207 request_allocation_size: std::env::var(
208 Config::RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES,
209 )
210 .ok()
211 .and_then(|v| v.parse().ok())
212 .unwrap_or(*Config::RWS_DEFAULT_REQUEST_ALLOCATION_SIZE_IN_BYTES),
213 }
214 }
215
216 /// The configuration from the environment, read once and shared: an
217 /// atomic load and an `Arc` clone per call instead of ten `getenv`s.
218 /// Rebuilt after [`invalidate`] — which `config_reload::reload()` (SIGHUP,
219 /// `POST /admin/config/reload`) and every config writer in the crate call,
220 /// so hot reload still takes effect on the next request.
221 pub fn current() -> Arc<ServerConfig> {
222 let generation = GENERATION.load(Ordering::SeqCst);
223 if let Some((built_at, config)) = CURRENT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
224 if *built_at == generation {
225 return Arc::clone(config);
226 }
227 }
228 // Built from the generation read *before* the environment: a change
229 // during the rebuild leaves the cache stale, not wrong.
230 let fresh = Arc::new(ServerConfig::from_env());
231 *CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some((generation, Arc::clone(&fresh)));
232 fresh
233 }
234}