Skip to main content

synapse_pingora/
reload.rs

1//! Configuration hot-reload via SIGHUP signal.
2//!
3//! Provides zero-downtime configuration updates by watching for SIGHUP
4//! and reloading configuration files without restarting the service.
5
6use parking_lot::RwLock;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::Arc;
10use std::time::Instant;
11use tracing::{error, info, warn};
12
13use crate::config::{ConfigError, ConfigFile, ConfigLoader};
14use crate::site_waf::SiteWafManager;
15use crate::tls::TlsManager;
16use crate::vhost::VhostMatcher;
17
18/// Reload statistics.
19#[derive(Debug, Default)]
20pub struct ReloadStats {
21    /// Total reload attempts
22    pub attempts: AtomicU64,
23    /// Successful reloads
24    pub successes: AtomicU64,
25    /// Failed reloads
26    pub failures: AtomicU64,
27    /// Last reload timestamp (Unix epoch seconds)
28    pub last_reload_time: AtomicU64,
29    /// Whether last reload succeeded
30    pub last_success: AtomicBool,
31}
32
33impl ReloadStats {
34    /// Records a reload attempt result.
35    pub fn record(&self, success: bool) {
36        self.attempts.fetch_add(1, Ordering::Relaxed);
37        if success {
38            self.successes.fetch_add(1, Ordering::Relaxed);
39        } else {
40            self.failures.fetch_add(1, Ordering::Relaxed);
41        }
42        self.last_success.store(success, Ordering::Relaxed);
43        self.last_reload_time.store(
44            std::time::SystemTime::now()
45                .duration_since(std::time::UNIX_EPOCH)
46                .map(|d| d.as_secs())
47                .unwrap_or(0),
48            Ordering::Relaxed,
49        );
50    }
51}
52
53/// Result of a configuration reload operation.
54#[derive(Debug)]
55pub struct ReloadResult {
56    /// Whether the reload succeeded
57    pub success: bool,
58    /// Error message if failed
59    pub error: Option<String>,
60    /// Number of sites loaded
61    pub sites_loaded: usize,
62    /// Number of TLS certificates loaded
63    pub certs_loaded: usize,
64    /// Reload duration in milliseconds
65    pub duration_ms: u64,
66}
67
68/// Configuration reloader with atomic swapping.
69pub struct ConfigReloader {
70    /// Path to configuration file
71    config_path: PathBuf,
72    /// Current configuration (atomically swappable)
73    current_config: Arc<RwLock<ConfigFile>>,
74    /// Current vhost matcher (atomically swappable)
75    vhost_matcher: Arc<RwLock<VhostMatcher>>,
76    /// Current TLS manager
77    tls_manager: Arc<TlsManager>,
78    /// Current WAF manager
79    waf_manager: Arc<RwLock<SiteWafManager>>,
80    /// Reload statistics
81    stats: ReloadStats,
82    /// Whether reload is in progress
83    reload_in_progress: AtomicBool,
84}
85
86impl ConfigReloader {
87    /// Creates a new reloader with the given configuration path.
88    pub fn new<P: AsRef<Path>>(config_path: P) -> Result<Self, ConfigError> {
89        let config_path = config_path.as_ref().to_path_buf();
90
91        // Load initial configuration
92        let config = ConfigLoader::load(&config_path)?;
93        let sites = ConfigLoader::to_site_configs(&config);
94
95        // Initialize vhost matcher
96        let vhost_matcher = VhostMatcher::new(sites.clone())
97            .map_err(|e| ConfigError::ValidationError(e.to_string()))?;
98
99        // Initialize TLS manager
100        let tls_manager = TlsManager::default();
101
102        // Initialize WAF manager
103        let mut waf_manager = SiteWafManager::new();
104        for site in &config.sites {
105            if let Some(waf_config) = &site.waf {
106                let site_waf = crate::site_waf::SiteWafConfig {
107                    enabled: waf_config.enabled,
108                    threshold: waf_config.threshold.unwrap_or(config.server.waf_threshold),
109                    ..Default::default()
110                };
111                waf_manager.add_site(&site.hostname, site_waf);
112            }
113        }
114
115        info!(
116            "Configuration reloader initialized with {} sites",
117            config.sites.len()
118        );
119
120        Ok(Self {
121            config_path,
122            current_config: Arc::new(RwLock::new(config)),
123            vhost_matcher: Arc::new(RwLock::new(vhost_matcher)),
124            tls_manager: Arc::new(tls_manager),
125            waf_manager: Arc::new(RwLock::new(waf_manager)),
126            stats: ReloadStats::default(),
127            reload_in_progress: AtomicBool::new(false),
128        })
129    }
130
131    /// Reloads the configuration from disk.
132    ///
133    /// This is thread-safe and can be called from a signal handler.
134    /// If a reload is already in progress, this returns immediately.
135    pub fn reload(&self) -> ReloadResult {
136        // Prevent concurrent reloads
137        if self.reload_in_progress.swap(true, Ordering::SeqCst) {
138            warn!("Reload already in progress, skipping");
139            return ReloadResult {
140                success: false,
141                error: Some("Reload already in progress".to_string()),
142                sites_loaded: 0,
143                certs_loaded: 0,
144                duration_ms: 0,
145            };
146        }
147
148        let start = Instant::now();
149        let result = self.do_reload();
150        let duration_ms = start.elapsed().as_millis() as u64;
151
152        self.stats.record(result.success);
153        self.reload_in_progress.store(false, Ordering::SeqCst);
154
155        ReloadResult {
156            duration_ms,
157            ..result
158        }
159    }
160
161    /// Performs the actual reload operation.
162    fn do_reload(&self) -> ReloadResult {
163        info!("Starting configuration reload from {:?}", self.config_path);
164
165        // Load new configuration
166        let new_config = match ConfigLoader::load(&self.config_path) {
167            Ok(config) => config,
168            Err(e) => {
169                error!("Failed to load configuration: {}", e);
170                return ReloadResult {
171                    success: false,
172                    error: Some(format!("Config load error: {}", e)),
173                    sites_loaded: 0,
174                    certs_loaded: 0,
175                    duration_ms: 0,
176                };
177            }
178        };
179
180        // Convert to site configs
181        let sites = ConfigLoader::to_site_configs(&new_config);
182        let sites_count = sites.len();
183
184        // Create new vhost matcher
185        let new_matcher = match VhostMatcher::new(sites.clone()) {
186            Ok(matcher) => matcher,
187            Err(e) => {
188                error!("Failed to create vhost matcher: {}", e);
189                return ReloadResult {
190                    success: false,
191                    error: Some(format!("Vhost matcher error: {}", e)),
192                    sites_loaded: 0,
193                    certs_loaded: 0,
194                    duration_ms: 0,
195                };
196            }
197        };
198
199        // Create new WAF manager
200        let mut new_waf_manager = SiteWafManager::new();
201        for site in &new_config.sites {
202            if let Some(waf_config) = &site.waf {
203                let site_waf = crate::site_waf::SiteWafConfig {
204                    enabled: waf_config.enabled,
205                    threshold: waf_config
206                        .threshold
207                        .unwrap_or(new_config.server.waf_threshold),
208                    ..Default::default()
209                };
210                new_waf_manager.add_site(&site.hostname, site_waf);
211            }
212        }
213
214        // Atomically swap configurations
215        {
216            let mut config = self.current_config.write();
217            *config = new_config;
218        }
219        {
220            let mut matcher = self.vhost_matcher.write();
221            *matcher = new_matcher;
222        }
223        {
224            let mut waf = self.waf_manager.write();
225            *waf = new_waf_manager;
226        }
227
228        // Reload TLS certificates
229        let tls_result = self.tls_manager.reload_all();
230        if !tls_result.is_success() {
231            warn!(
232                "TLS reload completed with errors: {} succeeded, {} failed",
233                tls_result.succeeded, tls_result.failed
234            );
235            for (domain, error) in &tls_result.errors {
236                warn!("  Failed to reload cert for {}: {}", domain, error);
237            }
238        }
239
240        info!(
241            "Configuration reload complete: {} sites loaded",
242            sites_count
243        );
244
245        ReloadResult {
246            success: true,
247            error: None,
248            sites_loaded: sites_count,
249            certs_loaded: self.tls_manager.cert_count(),
250            duration_ms: 0,
251        }
252    }
253
254    /// Returns the current configuration (read-only).
255    pub fn config(&self) -> Arc<RwLock<ConfigFile>> {
256        Arc::clone(&self.current_config)
257    }
258
259    /// Returns the current vhost matcher.
260    pub fn vhost_matcher(&self) -> Arc<RwLock<VhostMatcher>> {
261        Arc::clone(&self.vhost_matcher)
262    }
263
264    /// Returns the TLS manager.
265    pub fn tls_manager(&self) -> Arc<TlsManager> {
266        Arc::clone(&self.tls_manager)
267    }
268
269    /// Returns the WAF manager.
270    pub fn waf_manager(&self) -> Arc<RwLock<SiteWafManager>> {
271        Arc::clone(&self.waf_manager)
272    }
273
274    /// Returns reload statistics.
275    pub fn stats(&self) -> &ReloadStats {
276        &self.stats
277    }
278
279    /// Returns whether a reload is currently in progress.
280    pub fn is_reloading(&self) -> bool {
281        self.reload_in_progress.load(Ordering::Relaxed)
282    }
283}
284
285/// Sets up SIGHUP signal handler for configuration reload.
286///
287/// # Safety
288/// This function installs a signal handler. The handler must be async-signal-safe.
289#[cfg(unix)]
290pub fn setup_sighup_handler(_reloader: Arc<ConfigReloader>) {
291    use std::thread;
292
293    thread::spawn(move || {
294        // Note: In production, use signal-hook or tokio::signal
295        // This is a simplified version for demonstration
296        info!("SIGHUP handler ready for configuration reload");
297
298        // The actual signal handling would be:
299        // signal_hook::iterator::Signals::new(&[signal_hook::consts::SIGHUP])
300        //     .unwrap()
301        //     .forever()
302        //     .for_each(|_| { reloader.reload(); });
303
304        // For now, we just loop and wait for the thread to be interrupted
305        // In production, this would be driven by actual SIGHUP signals
306        loop {
307            std::thread::sleep(std::time::Duration::from_secs(60));
308            // The reload would be triggered by signal, not by timeout
309            // This loop just keeps the thread alive
310        }
311    });
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use std::io::Write;
318    use tempfile::NamedTempFile;
319
320    fn create_temp_config(content: &str) -> NamedTempFile {
321        let mut file = NamedTempFile::new().unwrap();
322        file.write_all(content.as_bytes()).unwrap();
323        file
324    }
325
326    const MINIMAL_CONFIG: &str = r#"
327sites:
328  - hostname: example.com
329    upstreams:
330      - host: 127.0.0.1
331        port: 8080
332"#;
333
334    #[test]
335    fn test_reloader_creation() {
336        let file = create_temp_config(MINIMAL_CONFIG);
337        let reloader = ConfigReloader::new(file.path()).unwrap();
338
339        assert!(!reloader.is_reloading());
340        assert_eq!(reloader.stats.attempts.load(Ordering::Relaxed), 0);
341    }
342
343    #[test]
344    fn test_reload_success() {
345        let file = create_temp_config(MINIMAL_CONFIG);
346        let reloader = ConfigReloader::new(file.path()).unwrap();
347
348        let result = reloader.reload();
349
350        assert!(result.success);
351        assert!(result.error.is_none());
352        assert_eq!(result.sites_loaded, 1);
353        assert_eq!(reloader.stats.successes.load(Ordering::Relaxed), 1);
354    }
355
356    #[test]
357    fn test_reload_failure() {
358        let file = create_temp_config(MINIMAL_CONFIG);
359        let reloader = ConfigReloader::new(file.path()).unwrap();
360
361        // Delete the file to cause reload failure
362        drop(file);
363
364        let result = reloader.reload();
365
366        assert!(!result.success);
367        assert!(result.error.is_some());
368        assert_eq!(reloader.stats.failures.load(Ordering::Relaxed), 1);
369    }
370
371    #[test]
372    fn test_reload_stats() {
373        let stats = ReloadStats::default();
374
375        stats.record(true);
376        stats.record(true);
377        stats.record(false);
378
379        assert_eq!(stats.attempts.load(Ordering::Relaxed), 3);
380        assert_eq!(stats.successes.load(Ordering::Relaxed), 2);
381        assert_eq!(stats.failures.load(Ordering::Relaxed), 1);
382        assert!(!stats.last_success.load(Ordering::Relaxed));
383    }
384
385    #[test]
386    fn test_concurrent_reload_prevention() {
387        let file = create_temp_config(MINIMAL_CONFIG);
388        let reloader = Arc::new(ConfigReloader::new(file.path()).unwrap());
389
390        // Simulate reload in progress
391        reloader.reload_in_progress.store(true, Ordering::SeqCst);
392
393        let result = reloader.reload();
394
395        assert!(!result.success);
396        assert!(result
397            .error
398            .as_ref()
399            .unwrap()
400            .contains("already in progress"));
401    }
402
403    #[test]
404    fn test_config_access() {
405        let file = create_temp_config(MINIMAL_CONFIG);
406        let reloader = ConfigReloader::new(file.path()).unwrap();
407
408        let config = reloader.config();
409        let config_read = config.read();
410
411        assert_eq!(config_read.sites.len(), 1);
412        assert_eq!(config_read.sites[0].hostname, "example.com");
413    }
414
415    #[test]
416    fn test_vhost_matcher_access() {
417        let file = create_temp_config(MINIMAL_CONFIG);
418        let reloader = ConfigReloader::new(file.path()).unwrap();
419
420        let matcher = reloader.vhost_matcher();
421        let matcher_read = matcher.read();
422
423        assert!(matcher_read.match_host("example.com").is_some());
424    }
425}