rws_http/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
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{Arc, RwLock};
33
34use crate::entry_point::Config;
35
36pub mod log_privacy;
37pub use log_privacy::LogPrivacy;
38
39/// Bumped whenever `RWS_CONFIG_*` variables change (see [`invalidate`]).
40static GENERATION: AtomicU64 = AtomicU64::new(1);
41
42/// The snapshot [`ServerConfig::current`] hands out, with the generation it
43/// was built at.
44static CURRENT: RwLock<Option<(u64, Arc<ServerConfig>)>> = RwLock::new(None);
45
46/// Mark the cached [`ServerConfig::current`] snapshot stale: the next call
47/// re-reads the environment. Called by everything in the crate that writes
48/// `RWS_CONFIG_*` variables (config file, command line, defaults,
49/// `config_reload::reload`); call it yourself after `std::env::set_var` of
50/// a setting that should take effect immediately.
51pub fn invalidate() {
52 GENERATION.fetch_add(1, Ordering::SeqCst);
53}
54
55/// Default `Content-Security-Policy` header value. Mirrors
56/// `Header::_CONTENT_SECURITY_POLICY_VALUE_DEFAULT` without creating a
57/// circular import (`server_config` ← `header` ← `cors` ← `server_config`).
58const CSP_DEFAULT: &str = "default-src 'self'";
59/// Default `Cache-Control` for static files: cache, but revalidate.
60#[doc(hidden)]
61pub const STATIC_CACHE_CONTROL_DEFAULT: &str = "no-cache";
62
63/// All runtime-configurable settings for one server instance.
64///
65/// Fields map 1-to-1 to `RWS_CONFIG_*` environment variable names documented
66/// in [`Config`]. Default values match the environment-variable defaults.
67///
68/// Construct via [`ServerConfig::from_env()`] at startup or
69/// [`ServerConfig::default()`] in tests.
70#[derive(Clone, Debug, PartialEq)]
71pub struct ServerConfig {
72 // ── CORS ─────────────────────────────────────────────────────────────────
73 /// `RWS_CONFIG_CORS_ALLOW_ALL` — when `true`, all cross-origin requests are
74 /// reflected back as allowed (echo the `Origin` header). Overrides all
75 /// other CORS fields. Default: `true`.
76 pub cors_allow_all: bool,
77 /// `RWS_CONFIG_CORS_ALLOW_ORIGINS` — comma-separated list of allowed
78 /// origins when `cors_allow_all` is `false`. Default: `""` (none).
79 pub cors_allow_origins: String,
80 /// `RWS_CONFIG_CORS_ALLOW_CREDENTIALS` — value for the
81 /// `Access-Control-Allow-Credentials` response header. Default: `""`.
82 pub cors_allow_credentials: String,
83 /// `RWS_CONFIG_CORS_ALLOW_METHODS` — value for
84 /// `Access-Control-Allow-Methods`. Default: `""`.
85 pub cors_allow_methods: String,
86 /// `RWS_CONFIG_CORS_ALLOW_HEADERS` — value for
87 /// `Access-Control-Allow-Headers`. Default: `""`.
88 pub cors_allow_headers: String,
89 /// `RWS_CONFIG_CORS_EXPOSE_HEADERS` — value for
90 /// `Access-Control-Expose-Headers`. Default: `""`.
91 pub cors_expose_headers: String,
92 /// `RWS_CONFIG_CORS_MAX_AGE` — value for `Access-Control-Max-Age`.
93 /// Default: `"86400"`.
94 pub cors_max_age: String,
95
96 // ── Security headers ──────────────────────────────────────────────────────
97 /// `RWS_CONFIG_CSP` — `Content-Security-Policy` header value. An empty
98 /// string suppresses the header. Unset: `default-src 'self'` when
99 /// [`security_headers`](Self::security_headers) is on, otherwise no header
100 /// (18.0.0; was always `default-src 'self'`).
101 pub csp: String,
102 /// `RWS_CONFIG_SECURITY_HEADERS` — send `X-Frame-Options: SAMEORIGIN`,
103 /// `Referrer-Policy` and `Permissions-Policy` on every response (and the
104 /// default CSP, unless `RWS_CONFIG_CSP` is set). Off by default (18.0.0;
105 /// was always on): nginx sends none of them, and over HTTP/2 every header
106 /// costs HPACK work. `X-Content-Type-Options: nosniff` is always sent.
107 pub security_headers: bool,
108 /// `RWS_CONFIG_NANOS_HEADERS` — send `Date-Unix-Epoch-Nanos` and, for
109 /// static files, `Last-Modified-Unix-Epoch-Nanos`. Off by default (18.0.0;
110 /// was always on): they change on every response, so HTTP/2 can't index
111 /// them, and `Date`/`Last-Modified` carry the same information to the second.
112 pub nanos_headers: bool,
113 /// `RWS_CONFIG_CLIENT_HINTS` — send `Accept-CH`, `Critical-CH` and the
114 /// client hints in `Vary` on every response. Off by default (18.0.0; was
115 /// always on): ~700 bytes per response, and `Critical-CH` makes Chromium
116 /// retry a request that came without the hints.
117 pub client_hints: bool,
118 /// `RWS_CONFIG_STATIC_CACHE_CONTROL` — `Cache-Control` for static files
119 /// (other responses keep `no-store, …`). Default `"no-cache"` (18.0.0; was
120 /// `no-store, …`): browsers keep the file and revalidate it with
121 /// `If-None-Match` / `If-Modified-Since` (a 304). An empty value keeps
122 /// the `no-store` default for static files too.
123 pub static_cache_control: String,
124 /// `RWS_CONFIG_ERROR_FORMAT` — `"text"` sends rws-made errors as plain
125 /// text (`502 Bad Gateway`) as before 18.17.0; anything else (default
126 /// `"problem"`) sends RFC 9457 `application/problem+json`.
127 pub error_format_text: bool,
128 /// `RWS_CONFIG_PROXY_STATUS` — when proxied responses carry
129 /// `Proxy-Status` (RFC 9209): `"always"` (default), `"errors"`, `"off"`.
130 pub proxy_status: String,
131 /// `RWS_CONFIG_PROXY_NAME` — this hop's name in `Proxy-Status` (and
132 /// `Cache-Status`). Default `"rws"`.
133 pub proxy_name: String,
134 /// `RWS_CONFIG_PROXY_STATUS_NEXT_HOP` — add the backend address
135 /// (`next-hop`) and failure text (`details`). Default `false`.
136 pub proxy_status_next_hop: bool,
137 /// `RWS_CONFIG_CACHE_STATUS` — `CacheLayer` responses carry
138 /// `Cache-Status` (RFC 9211). Default `true`.
139 pub cache_status: bool,
140 /// `RWS_CONFIG_FORWARDED_HEADER` — proxied requests carry RFC 7239
141 /// `Forwarded` beside `X-Forwarded-*`. Default on; `"off"` leaves it out.
142 pub forwarded_header: bool,
143 /// `trusted_proxies` (18.24.0): peers whose `X-Forwarded-*` and
144 /// `Forwarded` headers are passed on; from anyone else they are
145 /// replaced.
146 pub trusted_proxies: crate::ip_filter::TrustedProxies,
147 /// `RWS_CONFIG_CLIENT_IP` (18.27.0): `"forwarded"` (default) takes the
148 /// client's address from a trusted proxy's `X-Forwarded-For`
149 /// (`ip_filter::client_ip`); `"peer"` always uses the TCP peer.
150 pub client_ip_forwarded: bool,
151 /// `RWS_CONFIG_METRICS_ALLOW` (18.27.0): clients `GET /metrics` answers
152 /// (the client as `ip_filter::client_ip` sees it); anyone else gets a
153 /// `404`. Default loopback and the private ranges, where scrapers sit;
154 /// `*` everyone (before 18.27), `none` no one.
155 pub metrics_allow: crate::ip_filter::TrustedProxies,
156 /// `RWS_CONFIG_SHUTDOWN_DELAY_SECS` (18.27.0): on SIGTERM, `/readyz`
157 /// answers `503` at once but connections are still accepted this long
158 /// before draining, so a load balancer takes the pod out first. Default 5.
159 pub shutdown_delay_secs: u64,
160 /// `RWS_CONFIG_SHUTDOWN_TIMEOUT_SECS` (18.35.0): after the delay and
161 /// the listeners closing, how long to wait for requests still being
162 /// handled before exiting. Default 25 (Kubernetes' 30 s grace period
163 /// minus the delay).
164 pub shutdown_timeout_secs: u64,
165 /// `RWS_CONFIG_EARLY_HINTS_HTTP1` — also send `103 Early Hints` to
166 /// HTTP/1.1 clients. Default `false`: browsers use them only over HTTP/2
167 /// and HTTP/3, and some old HTTP/1.1 clients mishandle extra `1xx`
168 /// responses (18.12.0).
169 pub early_hints_http1: bool,
170 /// `RWS_CONFIG_COMPRESSION_DICTIONARIES` — URL patterns (`/js/app.*.js`,
171 /// comma-separated) of static files used as compression dictionaries:
172 /// later versions go out as `dcz` / `dcb` deltas against the one the
173 /// browser has (18.12.0). Empty by default.
174 pub compression_dictionaries: Vec<String>,
175 /// `RWS_CONFIG_DIRECTORY_LISTING` — list a directory that has no
176 /// `index.html`. Default `true`; `false` answers 404 instead (18.8.4).
177 pub directory_listing: bool,
178
179 // ── Server internals ──────────────────────────────────────────────────────
180 /// `RWS_CONFIG_LOG_FORMAT` — `"json"` or `"combined"`. Default: `"json"`.
181 pub log_format: String,
182 /// What the access log may say about clients (18.33.0):
183 /// `RWS_CONFIG_ACCESS_LOG_ANONYMIZE_IP`, `RWS_CONFIG_LOG_REDACT_HEADERS`,
184 /// `RWS_CONFIG_LOG_REDACT_QUERY`. Default: all off.
185 pub log_privacy: LogPrivacy,
186 /// `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` — bytes allocated for
187 /// incoming request parsing. Default: `10000`.
188 pub request_allocation_size: i64,
189}
190
191impl Default for ServerConfig {
192 fn default() -> Self {
193 Self {
194 cors_allow_all: Config::RWS_CONFIG_CORS_ALLOW_ALL_DEFAULT_VALUE
195 .eq_ignore_ascii_case("true"),
196 cors_allow_origins: Config::RWS_CONFIG_CORS_ALLOW_ORIGINS_DEFAULT_VALUE.to_string(),
197 cors_allow_credentials: Config::RWS_CONFIG_CORS_ALLOW_CREDENTIALS_DEFAULT_VALUE
198 .to_string(),
199 cors_allow_methods: Config::RWS_CONFIG_CORS_ALLOW_METHODS_DEFAULT_VALUE.to_string(),
200 cors_allow_headers: Config::RWS_CONFIG_CORS_ALLOW_HEADERS_DEFAULT_VALUE.to_string(),
201 cors_expose_headers: Config::RWS_CONFIG_CORS_EXPOSE_HEADERS_DEFAULT_VALUE.to_string(),
202 cors_max_age: Config::RWS_CONFIG_CORS_MAX_AGE_DEFAULT_VALUE.to_string(),
203 csp: String::new(),
204 security_headers: false,
205 nanos_headers: false,
206 client_hints: false,
207 static_cache_control: STATIC_CACHE_CONTROL_DEFAULT.to_string(),
208 error_format_text: false,
209 proxy_status: "always".to_string(),
210 proxy_name: "rws".to_string(),
211 proxy_status_next_hop: false,
212 cache_status: true,
213 forwarded_header: true,
214 trusted_proxies: crate::ip_filter::TrustedProxies::parse("").expect("the default parses"),
215 client_ip_forwarded: true,
216 metrics_allow: crate::ip_filter::TrustedProxies::parse("").expect("the default parses"),
217 shutdown_delay_secs: 5,
218 shutdown_timeout_secs: 25,
219 early_hints_http1: false,
220 compression_dictionaries: Vec::new(),
221 directory_listing: true,
222 log_format: Config::RWS_CONFIG_LOG_FORMAT_DEFAULT_VALUE.to_string(),
223 log_privacy: LogPrivacy::default(),
224 request_allocation_size: *Config::RWS_DEFAULT_REQUEST_ALLOCATION_SIZE_IN_BYTES,
225 }
226 }
227}
228
229impl ServerConfig {
230 /// Build a `ServerConfig` by reading all `RWS_CONFIG_*` environment
231 /// variables. Missing variables fall back to their default values.
232 ///
233 /// Each call reads every variable — the environment lock is taken once
234 /// per variable — so per-request code uses [`ServerConfig::current`].
235 pub fn from_env() -> Self {
236 let read = |key: &str| std::env::var(key).unwrap_or_default();
237 Self {
238 cors_allow_all: read(Config::RWS_CONFIG_CORS_ALLOW_ALL)
239 .eq_ignore_ascii_case("true"),
240 cors_allow_origins: read(Config::RWS_CONFIG_CORS_ALLOW_ORIGINS),
241 cors_allow_credentials: read(Config::RWS_CONFIG_CORS_ALLOW_CREDENTIALS),
242 cors_allow_methods: read(Config::RWS_CONFIG_CORS_ALLOW_METHODS),
243 cors_allow_headers: read(Config::RWS_CONFIG_CORS_ALLOW_HEADERS),
244 cors_expose_headers: read(Config::RWS_CONFIG_CORS_EXPOSE_HEADERS),
245 cors_max_age: read(Config::RWS_CONFIG_CORS_MAX_AGE),
246 csp: std::env::var("RWS_CONFIG_CSP").unwrap_or_else(|_| {
247 if read("RWS_CONFIG_SECURITY_HEADERS").eq_ignore_ascii_case("true") {
248 CSP_DEFAULT.to_string()
249 } else {
250 String::new()
251 }
252 }),
253 security_headers: read("RWS_CONFIG_SECURITY_HEADERS").eq_ignore_ascii_case("true"),
254 nanos_headers: read("RWS_CONFIG_NANOS_HEADERS").eq_ignore_ascii_case("true"),
255 client_hints: read("RWS_CONFIG_CLIENT_HINTS").eq_ignore_ascii_case("true"),
256 static_cache_control: std::env::var("RWS_CONFIG_STATIC_CACHE_CONTROL")
257 .unwrap_or_else(|_| STATIC_CACHE_CONTROL_DEFAULT.to_string()),
258 error_format_text: read("RWS_CONFIG_ERROR_FORMAT").trim().eq_ignore_ascii_case("text"),
259 proxy_status: match read("RWS_CONFIG_PROXY_STATUS").trim().to_ascii_lowercase().as_str() {
260 "off" | "false" | "none" => "off".to_string(),
261 "errors" => "errors".to_string(),
262 _ => "always".to_string(),
263 },
264 proxy_name: Some(read("RWS_CONFIG_PROXY_NAME").trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "rws".to_string()),
265 proxy_status_next_hop: read("RWS_CONFIG_PROXY_STATUS_NEXT_HOP").eq_ignore_ascii_case("true"),
266 cache_status: !read("RWS_CONFIG_CACHE_STATUS").trim().eq_ignore_ascii_case("false"),
267 forwarded_header: !matches!(read("RWS_CONFIG_FORWARDED_HEADER").trim().to_ascii_lowercase().as_str(), "off" | "false" | "none"),
268 // A bad list is reported by `rws --check`; here it falls back to
269 // the default rather than trusting everyone.
270 trusted_proxies: crate::ip_filter::TrustedProxies::parse(&read("RWS_CONFIG_TRUSTED_PROXIES"))
271 .unwrap_or_else(|_| crate::ip_filter::TrustedProxies::parse("").expect("the default parses")),
272 client_ip_forwarded: !read("RWS_CONFIG_CLIENT_IP").trim().eq_ignore_ascii_case("peer"),
273 metrics_allow: crate::ip_filter::TrustedProxies::parse(&read("RWS_CONFIG_METRICS_ALLOW"))
274 .unwrap_or_else(|_| crate::ip_filter::TrustedProxies::parse("").expect("the default parses")),
275 shutdown_delay_secs: read("RWS_CONFIG_SHUTDOWN_DELAY_SECS").trim().parse().unwrap_or(5),
276 shutdown_timeout_secs: read("RWS_CONFIG_SHUTDOWN_TIMEOUT_SECS").trim().parse().unwrap_or(25),
277 early_hints_http1: read("RWS_CONFIG_EARLY_HINTS_HTTP1").eq_ignore_ascii_case("true"),
278 compression_dictionaries: read("RWS_CONFIG_COMPRESSION_DICTIONARIES")
279 .split(',')
280 .map(|p| p.trim().to_string())
281 .filter(|p| p.starts_with('/') && crate::early_hints::valid(p))
282 .collect(),
283 directory_listing: !read("RWS_CONFIG_DIRECTORY_LISTING").eq_ignore_ascii_case("false"),
284 log_format: read(Config::RWS_CONFIG_LOG_FORMAT),
285 log_privacy: LogPrivacy::from_env(),
286 request_allocation_size: std::env::var(
287 Config::RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES,
288 )
289 .ok()
290 .and_then(|v| v.parse().ok())
291 .unwrap_or(*Config::RWS_DEFAULT_REQUEST_ALLOCATION_SIZE_IN_BYTES),
292 }
293 }
294
295 /// The configuration from the environment, read once and shared: an
296 /// atomic load and an `Arc` clone per call instead of ten `getenv`s.
297 /// Rebuilt after [`invalidate`] — which `config_reload::reload()` (SIGHUP,
298 /// `POST /admin/config/reload`) and every config writer in the crate call,
299 /// so hot reload still takes effect on the next request.
300 pub fn current() -> Arc<ServerConfig> {
301 let generation = GENERATION.load(Ordering::SeqCst);
302 if let Some((built_at, config)) = CURRENT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
303 if *built_at == generation {
304 return Arc::clone(config);
305 }
306 }
307 // Built from the generation read *before* the environment: a change
308 // during the rebuild leaves the cache stale, not wrong.
309 let fresh = Arc::new(ServerConfig::from_env());
310 *CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some((generation, Arc::clone(&fresh)));
311 fresh
312 }
313}