Skip to main content

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