rust_web_server/config_reload/mod.rs
1//! Hot configuration reload.
2//!
3//! Re-reads `rws.config.toml` without restarting the server. Non-binding
4//! settings — those that would require a new TCP socket (IP, port,
5//! thread count) or a different default TLS cert/key path — are logged as
6//! ignored. All other settings take effect on the next incoming request.
7//! On TLS builds a `SIGHUP` also rebuilds the TLS acceptor, so renewed
8//! certificate files and changed `[[virtual_host]]` entries are picked up.
9//!
10//! # Triggering a reload
11//!
12//! * **Unix signal**: send `SIGHUP` to the server process.
13//! ```bash
14//! kill -HUP $(pidof rws)
15//! ```
16//! * **HTTP endpoint**: `POST /admin/config/reload` (no body required).
17//!
18//! # What is hot-reloadable
19//!
20//! | Setting | Env var |
21//! |---------|---------|
22//! | CORS — all fields | `RWS_CONFIG_CORS_*` |
23//! | Rate-limit thresholds | `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`, `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` |
24//! | Log format | `RWS_CONFIG_LOG_FORMAT` |
25//! | Request allocation size | `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` |
26//! | Max body size | `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` |
27//! | Tera templates (`tera` feature, if `template::init()` was called) | re-read from disk — see [`crate::template`] |
28//! | Proxy `[[route]]`, `[[upstream]]`, `[middleware]` (config-driven proxy) | validated, then swapped in; a file with errors leaves the running routes in place — see [`crate::proxy_config::ConfigDrivenApp::reload_from_str`] |
29//! | Anything registered with [`on_reload`] | your hook |
30//!
31//! # What is NOT hot-reloadable (requires restart)
32//!
33//! | Setting | Why |
34//! |---------|-----|
35//! | IP / Port | Bound socket cannot be moved |
36//! | Thread count | Thread pool is fixed at startup |
37//! | Default TLS cert / key *path* | `run_tls` keeps the startup paths (the files themselves are re-read on `SIGHUP`) |
38//! | `[[tcp_proxy]]` / `[[udp_proxy]]` / `[[ws_proxy]]` | Bound listener sockets |
39
40#[cfg(test)]
41mod tests;
42
43use std::sync::{OnceLock, RwLock};
44use std::sync::atomic::AtomicBool;
45#[cfg(all(unix, feature = "http1"))]
46use std::sync::atomic::Ordering;
47
48use crate::entry_point::Config;
49use crate::entry_point::config_file::override_environment_variables_from_config;
50use crate::rate_limit;
51
52/// Global flag set by the SIGHUP signal handler.
53///
54/// The accept loop checks this between connections and calls [`reload`] when
55/// it is `true`, then resets it to `false`.
56pub static RELOAD_REQUESTED: AtomicBool = AtomicBool::new(false);
57
58/// Snapshot of all hot-reloadable configuration values at a point in time.
59///
60/// Obtain the current snapshot with [`current()`]. The snapshot is updated
61/// atomically every time [`reload()`] completes — no partial reads are possible.
62#[derive(Debug, Clone)]
63pub struct ConfigSnapshot {
64 /// `RWS_CONFIG_CORS_ALLOW_ALL`
65 pub cors_allow_all: bool,
66 /// `RWS_CONFIG_CORS_ALLOW_ORIGINS`
67 pub cors_allow_origins: String,
68 /// `RWS_CONFIG_CORS_ALLOW_CREDENTIALS`
69 pub cors_allow_credentials: String,
70 /// `RWS_CONFIG_CORS_ALLOW_METHODS`
71 pub cors_allow_methods: String,
72 /// `RWS_CONFIG_CORS_ALLOW_HEADERS`
73 pub cors_allow_headers: String,
74 /// `RWS_CONFIG_CORS_EXPOSE_HEADERS`
75 pub cors_expose_headers: String,
76 /// `RWS_CONFIG_CORS_MAX_AGE`
77 pub cors_max_age: String,
78 /// `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`
79 pub rate_limit_max_requests: u32,
80 /// `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS`
81 pub rate_limit_window_secs: u64,
82 /// `RWS_CONFIG_LOG_FORMAT`
83 pub log_format: String,
84 /// `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES`
85 pub request_allocation_size: i64,
86 /// `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`
87 pub max_body_size: u64,
88}
89
90impl ConfigSnapshot {
91 fn from_env() -> Self {
92 let read = |key: &str| std::env::var(key).unwrap_or_default();
93 Self {
94 cors_allow_all: read(Config::RWS_CONFIG_CORS_ALLOW_ALL)
95 .eq_ignore_ascii_case("true"),
96 cors_allow_origins: read(Config::RWS_CONFIG_CORS_ALLOW_ORIGINS),
97 cors_allow_credentials: read(Config::RWS_CONFIG_CORS_ALLOW_CREDENTIALS),
98 cors_allow_methods: read(Config::RWS_CONFIG_CORS_ALLOW_METHODS),
99 cors_allow_headers: read(Config::RWS_CONFIG_CORS_ALLOW_HEADERS),
100 cors_expose_headers: read(Config::RWS_CONFIG_CORS_EXPOSE_HEADERS),
101 cors_max_age: read(Config::RWS_CONFIG_CORS_MAX_AGE),
102 rate_limit_max_requests: std::env::var("RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS")
103 .ok()
104 .and_then(|v| v.parse().ok())
105 .unwrap_or(1000),
106 rate_limit_window_secs: std::env::var("RWS_CONFIG_RATE_LIMIT_WINDOW_SECS")
107 .ok()
108 .and_then(|v| v.parse().ok())
109 .unwrap_or(60),
110 log_format: read(Config::RWS_CONFIG_LOG_FORMAT),
111 request_allocation_size: std::env::var(
112 Config::RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES,
113 )
114 .ok()
115 .and_then(|v| v.parse().ok())
116 .unwrap_or(*Config::RWS_DEFAULT_REQUEST_ALLOCATION_SIZE_IN_BYTES),
117 max_body_size: crate::entry_point::get_max_body_size(),
118 }
119 }
120}
121
122static SNAPSHOT: OnceLock<RwLock<ConfigSnapshot>> = OnceLock::new();
123
124fn global() -> &'static RwLock<ConfigSnapshot> {
125 SNAPSHOT.get_or_init(|| RwLock::new(ConfigSnapshot::from_env()))
126}
127
128/// Returns a clone of the current hot-reloadable configuration snapshot.
129///
130/// This takes a brief read lock and clones a handful of strings — safe to call
131/// on every request if needed.
132pub fn current() -> ConfigSnapshot {
133 global().read().unwrap().clone()
134}
135
136/// Ask the running server to reload, exactly as `SIGHUP` does: on Unix it
137/// raises `SIGHUP` for this process, so the server's own handler reloads the
138/// config, the proxy routes and (TLS builds) the certificates; elsewhere it
139/// calls [`reload`]. Setting [`RELOAD_REQUESTED`] alone only reaches the
140/// HTTP/1.1-only server's accept loop — the async servers never read it, so
141/// the MCP `reload_config` tool did nothing in them.
142pub fn request_reload() {
143 #[cfg(all(unix, any(feature = "http1", feature = "http2")))]
144 // SAFETY: kill(2) on our own pid with a signal both servers handle.
145 unsafe {
146 libc::kill(libc::getpid(), libc::SIGHUP);
147 }
148 #[cfg(not(all(unix, any(feature = "http1", feature = "http2"))))]
149 reload();
150}
151
152/// Re-read `rws.config.toml` and apply all hot-reloadable changes in-place.
153///
154/// Called automatically when [`RELOAD_REQUESTED`] is set (SIGHUP on Unix) or
155/// from the `POST /admin/config/reload` handler.
156///
157/// Settings that cannot be changed without a restart (IP, port, thread count,
158/// TLS cert/key) are silently ignored — they are re-parsed from the file but
159/// have no effect until the next process start.
160pub fn reload() {
161 // Re-parse rws.config.toml → updates process env vars for CORS, log format, etc.
162 // Only one thread ever calls this (the signal handler or admin endpoint),
163 // while workers only read env vars, so the single-writer / many-readers
164 // pattern is safe in practice on all supported platforms.
165 override_environment_variables_from_config(None);
166 crate::server_config::invalidate();
167
168 let snapshot = ConfigSnapshot::from_env();
169
170 // Apply rate-limit changes to the live global limiter immediately.
171 rate_limit::global().set_limits(
172 snapshot.rate_limit_max_requests,
173 snapshot.rate_limit_window_secs,
174 );
175
176 // Publish the new snapshot atomically.
177 *global().write().unwrap() = snapshot.clone();
178
179 // Re-glob Tera templates from disk, if the `tera` feature is enabled and
180 // `template::init()` was actually called — a no-op otherwise.
181 #[cfg(feature = "tera")]
182 crate::template::reload_if_initialized();
183
184 for hook in hooks().lock().unwrap_or_else(|e| e.into_inner()).iter() {
185 hook();
186 }
187
188 println!(
189 "Config reloaded — cors_allow_all={} rate_limit={}/{} log_format={}",
190 snapshot.cors_allow_all,
191 snapshot.rate_limit_max_requests,
192 snapshot.rate_limit_window_secs,
193 snapshot.log_format,
194 );
195}
196
197type ReloadHook = Box<dyn Fn() + Send + Sync>;
198
199fn hooks() -> &'static std::sync::Mutex<Vec<ReloadHook>> {
200 static HOOKS: OnceLock<std::sync::Mutex<Vec<ReloadHook>>> = OnceLock::new();
201 HOOKS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
202}
203
204/// Run `hook` at the end of every [`reload`], after the settings have been
205/// re-read — for state that is built from the configuration file rather than
206/// read from `RWS_CONFIG_*` per request. The config-driven proxy registers
207/// one to reload its routes ([`crate::proxy_config::ConfigDrivenApp::reload_from_file`]).
208pub fn on_reload(hook: impl Fn() + Send + Sync + 'static) {
209 hooks().lock().unwrap_or_else(|e| e.into_inner()).push(Box::new(hook));
210}
211
212/// Install a `SIGHUP` signal handler that sets [`RELOAD_REQUESTED`].
213///
214/// Call this once at server startup (before the accept loop). Safe to call
215/// on non-Unix platforms — it compiles to a no-op.
216///
217/// The handler itself does the minimum allowed in a signal context: it stores
218/// `true` into an `AtomicBool`. The actual [`reload()`] call happens on the
219/// main thread between connection accepts.
220pub fn install_sighup_handler() {
221 #[cfg(all(unix, feature = "http1"))]
222 // SAFETY: The handler only writes to a process-global AtomicBool which is
223 // async-signal-safe. No allocation, no locks, no I/O.
224 unsafe {
225 libc::signal(libc::SIGHUP, sighup_handler as *const () as libc::sighandler_t);
226 }
227}
228
229#[cfg(all(unix, feature = "http1"))]
230extern "C" fn sighup_handler(_: libc::c_int) {
231 RELOAD_REQUESTED.store(true, Ordering::SeqCst);
232}