Skip to main content

rws_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 `template` |
28//! | Proxy `[[route]]`, `[[upstream]]`, `[middleware]` (config-driven proxy) | validated, then swapped in; a file with errors leaves the running routes in place — see `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;
45use std::sync::atomic::Ordering;
46
47use crate::entry_point::Config;
48use crate::entry_point::config_file::override_environment_variables_from_config;
49use crate::rate_limit;
50
51/// Global flag set by the SIGHUP signal handler.
52///
53/// The accept loop checks this between connections and calls [`reload`] when
54/// it is `true`, then resets it to `false`.
55pub static RELOAD_REQUESTED: AtomicBool = AtomicBool::new(false);
56
57/// Completed [`reload`]s since the process started.
58static RELOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
59
60fn last_errors() -> &'static std::sync::Mutex<Vec<String>> {
61    static ERRORS: OnceLock<std::sync::Mutex<Vec<String>>> = OnceLock::new();
62    ERRORS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
63}
64
65/// How many [`reload`]s have completed, and the errors the last one's hooks
66/// reported with [`report_error`] (empty: it applied cleanly). For the admin
67/// API, which requests a reload and waits for the count to move.
68pub fn last_reload() -> (u64, Vec<String>) {
69    let errors = last_errors().lock().unwrap_or_else(|e| e.into_inner()).clone();
70    (RELOADS.load(Ordering::SeqCst), errors)
71}
72
73/// A reload with one reported error, without re-reading anything: for the
74/// admin API's tests.
75#[cfg(any(test, feature = "test-support"))]
76#[doc(hidden)]
77pub fn on_reload_test_error() {
78    let mut errors = last_errors().lock().unwrap_or_else(|e| e.into_inner());
79    errors.clear();
80    errors.push("test reload error".to_string());
81    drop(errors);
82    RELOADS.fetch_add(1, Ordering::SeqCst);
83}
84
85/// A reload hook couldn't apply its part (the old state stays): recorded for
86/// [`last_reload`] until the next reload.
87pub fn report_error(message: impl Into<String>) {
88    last_errors().lock().unwrap_or_else(|e| e.into_inner()).push(message.into());
89}
90
91/// Snapshot of all hot-reloadable configuration values at a point in time.
92///
93/// Obtain the current snapshot with [`current()`]. The snapshot is updated
94/// atomically every time [`reload()`] completes — no partial reads are possible.
95#[derive(Debug, Clone)]
96pub struct ConfigSnapshot {
97    /// `RWS_CONFIG_CORS_ALLOW_ALL`
98    pub cors_allow_all: bool,
99    /// `RWS_CONFIG_CORS_ALLOW_ORIGINS`
100    pub cors_allow_origins: String,
101    /// `RWS_CONFIG_CORS_ALLOW_CREDENTIALS`
102    pub cors_allow_credentials: String,
103    /// `RWS_CONFIG_CORS_ALLOW_METHODS`
104    pub cors_allow_methods: String,
105    /// `RWS_CONFIG_CORS_ALLOW_HEADERS`
106    pub cors_allow_headers: String,
107    /// `RWS_CONFIG_CORS_EXPOSE_HEADERS`
108    pub cors_expose_headers: String,
109    /// `RWS_CONFIG_CORS_MAX_AGE`
110    pub cors_max_age: String,
111    /// `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`
112    pub rate_limit_max_requests: u32,
113    /// `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS`
114    pub rate_limit_window_secs: u64,
115    /// `RWS_CONFIG_LOG_FORMAT`
116    pub log_format: String,
117    /// `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES`
118    pub request_allocation_size: i64,
119    /// `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`
120    pub max_body_size: u64,
121}
122
123impl ConfigSnapshot {
124    fn from_env() -> Self {
125        let read = |key: &str| std::env::var(key).unwrap_or_default();
126        Self {
127            cors_allow_all: read(Config::RWS_CONFIG_CORS_ALLOW_ALL)
128                .eq_ignore_ascii_case("true"),
129            cors_allow_origins:     read(Config::RWS_CONFIG_CORS_ALLOW_ORIGINS),
130            cors_allow_credentials: read(Config::RWS_CONFIG_CORS_ALLOW_CREDENTIALS),
131            cors_allow_methods:     read(Config::RWS_CONFIG_CORS_ALLOW_METHODS),
132            cors_allow_headers:     read(Config::RWS_CONFIG_CORS_ALLOW_HEADERS),
133            cors_expose_headers:    read(Config::RWS_CONFIG_CORS_EXPOSE_HEADERS),
134            cors_max_age:           read(Config::RWS_CONFIG_CORS_MAX_AGE),
135            rate_limit_max_requests: std::env::var("RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS")
136                .ok()
137                .and_then(|v| v.parse().ok())
138                .unwrap_or(1000),
139            rate_limit_window_secs: std::env::var("RWS_CONFIG_RATE_LIMIT_WINDOW_SECS")
140                .ok()
141                .and_then(|v| v.parse().ok())
142                .unwrap_or(60),
143            log_format: read(Config::RWS_CONFIG_LOG_FORMAT),
144            request_allocation_size: std::env::var(
145                Config::RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES,
146            )
147            .ok()
148            .and_then(|v| v.parse().ok())
149            .unwrap_or(*Config::RWS_DEFAULT_REQUEST_ALLOCATION_SIZE_IN_BYTES),
150            max_body_size: crate::entry_point::get_max_body_size(),
151        }
152    }
153}
154
155static SNAPSHOT: OnceLock<RwLock<ConfigSnapshot>> = OnceLock::new();
156
157fn global() -> &'static RwLock<ConfigSnapshot> {
158    SNAPSHOT.get_or_init(|| RwLock::new(ConfigSnapshot::from_env()))
159}
160
161/// Returns a clone of the current hot-reloadable configuration snapshot.
162///
163/// This takes a brief read lock and clones a handful of strings — safe to call
164/// on every request if needed.
165pub fn current() -> ConfigSnapshot {
166    global().read().unwrap().clone()
167}
168
169/// Ask the running server to reload, exactly as `SIGHUP` does: on Unix it
170/// raises `SIGHUP` for this process, so the server's own handler reloads the
171/// config, the proxy routes and (TLS builds) the certificates; elsewhere it
172/// calls [`reload`]. Setting [`RELOAD_REQUESTED`] alone only reaches the
173/// HTTP/1.1-only server's accept loop — the async servers never read it, so
174/// the MCP `reload_config` tool did nothing in them.
175///
176/// Until a server has installed its `SIGHUP` handler (the async servers do
177/// it in their accept loops, after `main` has started fleet, the config
178/// watcher and the admin API), the signal's default action would end the
179/// process, so this calls [`reload`] directly instead.
180pub fn request_reload() {
181    signal_or_reload(SIGHUP_HANDLED.load(Ordering::SeqCst));
182}
183
184/// Set once a `SIGHUP` handler is in place: [`install_sighup_handler`], or
185/// the async servers' first `tokio::signal` registration (tokio keeps its
186/// handler for the life of the process).
187static SIGHUP_HANDLED: AtomicBool = AtomicBool::new(false);
188
189#[cfg_attr(not(any(feature = "http2", all(unix, feature = "http1"))), allow(dead_code))]
190pub(crate) fn note_sighup_handled() {
191    SIGHUP_HANDLED.store(true, Ordering::SeqCst);
192}
193
194/// `true` when it raised `SIGHUP`, `false` when it reloaded in place.
195fn signal_or_reload(handled: bool) -> bool {
196    #[cfg(all(unix, any(feature = "http1", feature = "http2")))]
197    if handled {
198        // SAFETY: kill(2) on our own pid with a signal a handler is installed for.
199        unsafe {
200            libc::kill(libc::getpid(), libc::SIGHUP);
201        }
202        return true;
203    }
204    let _ = handled;
205    reload();
206    false
207}
208
209/// Re-read `rws.config.toml` and apply all hot-reloadable changes in-place.
210///
211/// Called automatically when [`RELOAD_REQUESTED`] is set (SIGHUP on Unix) or
212/// from the `POST /admin/config/reload` handler.
213///
214/// Settings that cannot be changed without a restart (IP, port, thread count,
215/// TLS cert/key) are silently ignored — they are re-parsed from the file but
216/// have no effect until the next process start.
217pub fn reload() {
218    #[cfg(all(unix, any(feature = "http1", feature = "http2")))]
219    crate::systemd::reloading();
220    reload_settings();
221    #[cfg(all(unix, any(feature = "http1", feature = "http2")))]
222    crate::systemd::reloaded();
223}
224
225fn reload_settings() {
226    // Re-parse rws.config.toml → updates process env vars for CORS, log format, etc.
227    // Only one thread ever calls this (the signal handler or admin endpoint),
228    // while workers only read env vars, so the single-writer / many-readers
229    // pattern is safe in practice on all supported platforms.
230    last_errors().lock().unwrap_or_else(|e| e.into_inner()).clear();
231    override_environment_variables_from_config(None);
232    crate::server_config::invalidate();
233
234    let snapshot = ConfigSnapshot::from_env();
235
236    // Apply rate-limit changes to the live global limiter immediately.
237    rate_limit::global().set_limits(
238        snapshot.rate_limit_max_requests,
239        snapshot.rate_limit_window_secs,
240    );
241
242    // Publish the new snapshot atomically.
243    *global().write().unwrap() = snapshot.clone();
244
245    // Hooks: proxy routes, Tera templates (`template::init()` registers one), …
246    for hook in hooks().lock().unwrap_or_else(|e| e.into_inner()).iter() {
247        hook();
248    }
249    RELOADS.fetch_add(1, Ordering::SeqCst);
250
251    println!(
252        "Config reloaded — cors_allow_all={} rate_limit={}/{} log_format={}",
253        snapshot.cors_allow_all,
254        snapshot.rate_limit_max_requests,
255        snapshot.rate_limit_window_secs,
256        snapshot.log_format,
257    );
258}
259
260type ReloadHook = Box<dyn Fn() + Send + Sync>;
261
262fn hooks() -> &'static std::sync::Mutex<Vec<ReloadHook>> {
263    static HOOKS: OnceLock<std::sync::Mutex<Vec<ReloadHook>>> = OnceLock::new();
264    HOOKS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
265}
266
267/// Run `hook` at the end of every [`reload`], after the settings have been
268/// re-read — for state that is built from the configuration file rather than
269/// read from `RWS_CONFIG_*` per request. The config-driven proxy registers
270/// one to reload its routes (`proxy_config::ConfigDrivenApp::reload_from_file`).
271pub fn on_reload(hook: impl Fn() + Send + Sync + 'static) {
272    hooks().lock().unwrap_or_else(|e| e.into_inner()).push(Box::new(hook));
273}
274
275/// Install a `SIGHUP` signal handler that sets [`RELOAD_REQUESTED`].
276///
277/// Call this once at server startup (before the accept loop). Safe to call
278/// on non-Unix platforms — it compiles to a no-op.
279///
280/// The handler itself does the minimum allowed in a signal context: it stores
281/// `true` into an `AtomicBool`. The actual [`reload()`] call happens on the
282/// main thread between connection accepts.
283pub fn install_sighup_handler() {
284    #[cfg(all(unix, feature = "http1"))]
285    // SAFETY: The handler only writes to a process-global AtomicBool which is
286    // async-signal-safe. No allocation, no locks, no I/O.
287    unsafe {
288        libc::signal(libc::SIGHUP, sighup_handler as *const () as libc::sighandler_t);
289    }
290    #[cfg(all(unix, feature = "http1"))]
291    note_sighup_handled();
292}
293
294#[cfg(all(unix, feature = "http1"))]
295extern "C" fn sighup_handler(_: libc::c_int) {
296    RELOAD_REQUESTED.store(true, Ordering::SeqCst);
297}