Skip to main content

rust_web_server/config_watch/
mod.rs

1//! Reload when the config file or a certificate changes (`watch = true`, #10).
2//!
3//! A background thread polls the size and modification time of
4//! `rws.config.toml` (or `RWS_CONFIG_FILE`) and of every configured TLS
5//! certificate and key once a second. A change that looks the same on two
6//! polls in a row — an editor or `certbot` finished writing — triggers the
7//! same validated reload as `SIGHUP` ([`crate::config_reload::request_reload`]):
8//! a broken config is rejected and the running one keeps serving. The set of
9//! files is re-read on every poll, so a reload that points at new
10//! certificates starts watching them. Off by default, like Caddy's `--watch`.
11
12use std::path::PathBuf;
13use std::time::{Duration, SystemTime};
14
15/// `RWS_CONFIG_WATCH`: `true` to reload when a watched file changes.
16pub const RWS_CONFIG_WATCH: &str = "RWS_CONFIG_WATCH";
17
18/// How often the files are checked.
19pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
20
21/// Whether `RWS_CONFIG_WATCH` is on.
22pub fn enabled() -> bool {
23    std::env::var(RWS_CONFIG_WATCH).is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on"))
24}
25
26/// The files whose change means a reload: the config file and every
27/// configured certificate and key (the default pair and each virtual host's).
28pub fn watched_paths() -> Vec<PathBuf> {
29    let var = |key: &str| std::env::var(key).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
30    let mut paths = vec![PathBuf::from(var("RWS_CONFIG_FILE").unwrap_or_else(|| "rws.config.toml".to_string()))];
31    for key in [crate::entry_point::Config::RWS_CONFIG_TLS_CERT_FILE, crate::entry_point::Config::RWS_CONFIG_TLS_KEY_FILE] {
32        paths.extend(var(key).map(PathBuf::from));
33    }
34    // CRLs for client certificates: a new list applies without a restart.
35    if let Some(files) = var("RWS_CONFIG_TLS_CLIENT_CRL_FILE") {
36        paths.extend(files.split(',').map(str::trim).filter(|p| !p.is_empty()).map(PathBuf::from));
37    }
38    for i in 0.. {
39        if var(&format!("RWS_CONFIG_VIRTUAL_HOST_{i}_DOMAIN")).is_none() {
40            break;
41        }
42        for suffix in ["CERT_FILE", "KEY_FILE"] {
43            paths.extend(var(&format!("RWS_CONFIG_VIRTUAL_HOST_{i}_{suffix}")).map(PathBuf::from));
44        }
45    }
46    // A key on a token (`pkcs11:`) isn't a file.
47    paths.retain(|p| !p.to_string_lossy().trim_start().to_ascii_lowercase().starts_with("pkcs11:"));
48    paths.sort();
49    paths.dedup();
50    paths
51}
52
53/// Size and modification time of each file (`None` while it doesn't exist).
54type Snapshot = Vec<(PathBuf, Option<(u64, SystemTime)>)>;
55
56fn snapshot(paths: Vec<PathBuf>) -> Snapshot {
57    paths
58        .into_iter()
59        .map(|path| {
60            let state = std::fs::metadata(&path).ok().and_then(|md| Some((md.len(), md.modified().ok()?)));
61            (path, state)
62        })
63        .collect()
64}
65
66/// Change detection, one [`Watcher::poll`] per interval.
67pub struct Watcher<F: FnMut() -> Vec<PathBuf>> {
68    paths: F,
69    applied: Snapshot,
70    pending: Option<Snapshot>,
71}
72
73impl<F: FnMut() -> Vec<PathBuf>> Watcher<F> {
74    /// A watcher of the files `paths` lists (asked again on every poll),
75    /// taking their current state as the applied one.
76    pub fn new(mut paths: F) -> Self {
77        let applied = snapshot(paths());
78        Watcher { paths, applied, pending: None }
79    }
80
81    /// `true` when the files changed and have stayed the same since the
82    /// previous poll: time to reload.
83    pub fn poll(&mut self) -> bool {
84        let now = snapshot((self.paths)());
85        if now == self.applied {
86            self.pending = None;
87            return false;
88        }
89        if self.pending.as_ref() == Some(&now) {
90            self.applied = now;
91            self.pending = None;
92            return true;
93        }
94        self.pending = Some(now);
95        false
96    }
97}
98
99/// Start the watcher thread when `RWS_CONFIG_WATCH` is on; the `rws` binary
100/// calls it after reading its configuration.
101pub fn start() {
102    if !enabled() {
103        return;
104    }
105    let spawned = std::thread::Builder::new().name("rws-config-watch".to_string()).spawn(|| {
106        let mut watcher = Watcher::new(watched_paths);
107        loop {
108            std::thread::sleep(POLL_INTERVAL);
109            if watcher.poll() {
110                println!("config watch: a watched file changed; reloading");
111                crate::config_reload::request_reload();
112            }
113        }
114    });
115    if let Err(e) = spawned {
116        eprintln!("config watch: could not start: {e}");
117    }
118}
119
120#[cfg(test)]
121mod tests;