Skip to main content

amaters_server/
hot_reload.rs

1//! Hot-reload support for server configuration and TLS certificates.
2//!
3//! # Config reload (SIGHUP)
4//!
5//! SIGHUP handling is provided by [`crate::shutdown::setup_sighup_handler`]
6//! together with [`crate::config::ReloadableConfig`], which already implement
7//! the full SIGHUP → re-parse → atomic swap → log-diff pipeline.
8//!
9//! [`spawn_config_reloader`] is a thin façade that wires those existing
10//! building blocks together and returns a `JoinHandle` so callers can
11//! cancel the background task when needed.
12//!
13//! # TLS certificate reload
14//!
15//! [`spawn_tls_reloader`] watches a directory for changes to cert/key files
16//! using [`notify`].  On a detected change it reloads the PEM bytes and
17//! atomically swaps them into an [`arc_swap::ArcSwap<TlsCreds>`].
18//!
19//! **Integration note:** tonic's `ServerTlsConfig` is consumed by
20//! `tonic::transport::Server::builder().tls_config(...)` at server-start time.
21//! To use live-rotated credentials the server must be built with a custom
22//! `rustls::ServerConfig` derived from the shared [`TlsCreds`] store, so that
23//! each new TLS handshake uses the latest certificate.  The
24//! [`spawn_tls_reloader`] API intentionally exposes the raw [`TlsCreds`] store
25//! so the caller can build that custom acceptor; helper
26//! [`build_server_tls_config`] converts the current credentials into a
27//! `tonic::transport::ServerTlsConfig` for use at startup or reconnect.
28
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use arc_swap::ArcSwap;
33use notify::{Event, RecursiveMode, Watcher};
34use parking_lot::RwLock;
35use thiserror::Error;
36use tokio::task::JoinHandle;
37use tonic::transport::{Identity, ServerTlsConfig};
38use tracing::{error, info, warn};
39
40use amaters_net::tls_acceptor::{TlsCredsRef, build_rustls_config};
41
42use crate::config::{ConfigError, ReloadableConfig, ServerConfig};
43
44// ---------------------------------------------------------------------------
45// Error type
46// ---------------------------------------------------------------------------
47
48/// Errors that can occur during hot-reload operations.
49#[derive(Debug, Error)]
50pub enum HotReloadError {
51    /// File-system watcher could not be created or watch path could not be added.
52    #[error("File watcher error: {0}")]
53    Watch(#[from] notify::Error),
54
55    /// An I/O error occurred while reading a certificate or key file.
56    #[error("IO error reading TLS file: {0}")]
57    Io(#[from] std::io::Error),
58
59    /// The PEM bytes loaded from disk could not be used to build a TLS identity.
60    #[error("TLS credential error: {0}")]
61    Tls(String),
62
63    /// A `rustls::ServerConfig` could not be built from the provided creds —
64    /// surfaced from `amaters_net::tls_acceptor::build_rustls_config`.
65    #[error("rustls config error: {0}")]
66    Rustls(String),
67
68    /// The config reload itself reported an error.
69    #[error("Config error: {0}")]
70    Config(#[from] ConfigError),
71}
72
73// ---------------------------------------------------------------------------
74// Raw TLS credentials (cert + key PEM bytes)
75// ---------------------------------------------------------------------------
76
77/// Raw PEM bytes for a TLS certificate and private key.
78///
79/// Stored inside an [`ArcSwap`] so that new credentials can be swapped in
80/// atomically without stopping the server.  Any code that builds a
81/// `rustls::ServerConfig` or `tonic::transport::ServerTlsConfig` should load
82/// the current value from the `ArcSwap` immediately before use so it picks up
83/// any rotation that happened since the last call.
84#[derive(Clone, Debug)]
85pub struct TlsCreds {
86    /// PEM-encoded certificate chain.
87    pub cert_pem: Vec<u8>,
88    /// PEM-encoded private key.
89    pub key_pem: Vec<u8>,
90}
91
92impl TlsCreds {
93    /// Load `TlsCreds` from cert and key files on disk.
94    pub fn load_from_files(cert_path: &Path, key_path: &Path) -> Result<Self, HotReloadError> {
95        let cert_pem = std::fs::read(cert_path)?;
96        let key_pem = std::fs::read(key_path)?;
97        Ok(Self { cert_pem, key_pem })
98    }
99
100    /// Convert these credentials into a `tonic` [`ServerTlsConfig`].
101    pub fn to_server_tls_config(&self) -> ServerTlsConfig {
102        let identity = Identity::from_pem(&self.cert_pem, &self.key_pem);
103        ServerTlsConfig::new().identity(identity)
104    }
105}
106
107// ---------------------------------------------------------------------------
108// Build helpers
109// ---------------------------------------------------------------------------
110
111/// Load cert + key from disk and construct a `tonic` [`ServerTlsConfig`].
112///
113/// This is equivalent to calling `TlsCreds::load_from_files` followed by
114/// `TlsCreds::to_server_tls_config`, provided as a convenience.
115pub fn build_server_tls_config(
116    cert_path: &Path,
117    key_path: &Path,
118) -> Result<ServerTlsConfig, HotReloadError> {
119    Ok(TlsCreds::load_from_files(cert_path, key_path)?.to_server_tls_config())
120}
121
122// ---------------------------------------------------------------------------
123// Item 10: SIGHUP config reloader façade
124// ---------------------------------------------------------------------------
125
126/// Spawn a background task that reloads `config` on `SIGHUP`.
127///
128/// This is a thin façade over the existing [`crate::shutdown::setup_sighup_handler`]
129/// infrastructure.  It is provided so callers have a single, consistent API
130/// that returns a [`JoinHandle`] they can cancel when the server shuts down.
131///
132/// On non-Unix platforms the spawned task logs a warning and exits immediately.
133///
134/// # Config reload semantics
135///
136/// When `SIGHUP` is received:
137/// 1. The config file at `config_path` is re-parsed.
138/// 2. The new config is validated.
139/// 3. Only *reloadable* sections (logging, metrics, compaction, rate-limits) are
140///    applied atomically via `RwLock::write`.
141/// 4. Non-reloadable sections (bind address, storage engine, TLS cert *path*, …)
142///    are logged as skipped.
143/// 5. If validation fails, the old config is preserved and an error is logged.
144///
145/// See [`crate::config::ReloadableSection`] and [`crate::config::ConfigDiff`] for details.
146pub async fn spawn_config_reloader(
147    config_path: PathBuf,
148    config: Arc<RwLock<ServerConfig>>,
149) -> JoinHandle<()> {
150    // Build a ReloadableConfig backed by the same lock so that any writes made
151    // through setup_sighup_handler are visible to code that holds a reference
152    // to `config` directly.
153    //
154    // ReloadableConfig wraps Arc<RwLock<ServerConfig>> internally.  We create
155    // one from the provided config, then hand it to setup_sighup_handler.
156    let initial = config.read().clone();
157    let reloadable = ReloadableConfig::new(initial);
158    reloadable.set_config_path(config_path.clone());
159
160    // Clone the reloadable so we can move it into the task below.
161    let reloadable_for_task = reloadable.clone();
162    let config_for_task = config.clone();
163
164    tokio::spawn(async move {
165        #[cfg(unix)]
166        {
167            use tokio::signal::unix::{SignalKind, signal};
168
169            let mut hangup = match signal(SignalKind::hangup()) {
170                Ok(s) => s,
171                Err(e) => {
172                    warn!("Failed to register SIGHUP signal handler: {}", e);
173                    return;
174                }
175            };
176
177            loop {
178                hangup.recv().await;
179                info!("SIGHUP received — reloading config from {:?}", config_path);
180
181                // Reload through ReloadableConfig (validates + section-aware swap).
182                match reloadable_for_task.reload_from_stored_path() {
183                    Ok(report) if report.success => {
184                        // Mirror the updated reloadable snapshot back to the raw lock
185                        // so callers holding `Arc<RwLock<ServerConfig>>` see the change.
186                        let updated = reloadable_for_task.snapshot();
187                        *config_for_task.write() = updated;
188                        info!("Config reloaded successfully: {}", report);
189                    }
190                    Ok(report) => {
191                        error!("Config reload failed — keeping old config: {}", report);
192                    }
193                    Err(e) => {
194                        error!("Config reload error — keeping old config: {}", e);
195                    }
196                }
197            }
198        }
199
200        #[cfg(not(unix))]
201        {
202            warn!(
203                "SIGHUP config reload is only supported on Unix platforms. \
204                 Use ReloadableConfig::manual_reload() as an alternative."
205            );
206        }
207    })
208}
209
210// ---------------------------------------------------------------------------
211// Item 11: TLS certificate file watcher
212// ---------------------------------------------------------------------------
213
214/// Spawn a background task that watches TLS cert and key files for changes.
215///
216/// When a change is detected in the directory containing `cert_path`, the
217/// cert and key are reloaded from disk and the new [`TlsCreds`] is atomically
218/// stored via [`ArcSwap::store`].
219///
220/// New connections that read from the [`ArcSwap`] after the swap will use the
221/// new credentials; existing connections complete with whatever credentials
222/// they negotiated at handshake time.
223///
224/// Returns an error if the file-system watcher cannot be initialised.
225///
226/// # Integration
227///
228/// ```rust,ignore
229/// let creds = Arc::new(ArcSwap::from_pointee(
230///     TlsCreds::load_from_files(&cert_path, &key_path)?,
231/// ));
232///
233/// spawn_tls_reloader(cert_path, key_path, Arc::clone(&creds)).await?;
234///
235/// // Build initial tls config from creds for tonic:
236/// let tls_config = creds.load().to_server_tls_config();
237/// ```
238pub async fn spawn_tls_reloader(
239    cert_path: PathBuf,
240    key_path: PathBuf,
241    tls_creds: Arc<ArcSwap<TlsCreds>>,
242) -> Result<(), HotReloadError> {
243    // notify v8 uses a sync channel; we bridge it to an async mpsc so the
244    // spawned task can use .await without blocking the tokio thread.
245    let (tx, mut rx) = tokio::sync::mpsc::channel::<notify::Result<Event>>(16);
246
247    let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
248        // best-effort: if the channel is full or closed, silently drop the event.
249        let _ = tx.blocking_send(event);
250    })?;
251
252    // Watch the directory that contains the cert file (non-recursive).
253    let cert_dir = cert_path.parent().unwrap_or_else(|| Path::new("."));
254    watcher.watch(cert_dir, RecursiveMode::NonRecursive)?;
255
256    // Clone paths for use inside the spawned task.
257    let cert_path_task = cert_path.clone();
258    let key_path_task = key_path.clone();
259
260    tokio::spawn(async move {
261        // Keep `watcher` alive inside the task.
262        let _watcher = watcher;
263
264        while let Some(event) = rx.recv().await {
265            match event {
266                Ok(e) => {
267                    // Only reload on events that touch the cert or key file.
268                    let relevant = e
269                        .paths
270                        .iter()
271                        .any(|p| p == &cert_path_task || p == &key_path_task);
272
273                    if !relevant {
274                        continue;
275                    }
276
277                    match TlsCreds::load_from_files(&cert_path_task, &key_path_task) {
278                        Ok(new_creds) => {
279                            tls_creds.store(Arc::new(new_creds));
280                            info!("TLS credentials reloaded from {:?}", cert_path_task);
281                        }
282                        Err(e) => {
283                            error!("TLS reload failed — keeping existing credentials: {}", e);
284                        }
285                    }
286                }
287                Err(e) => {
288                    warn!("File-watcher error (TLS reloader): {}", e);
289                }
290            }
291        }
292    });
293
294    Ok(())
295}
296
297// ---------------------------------------------------------------------------
298// Live rustls config rotation (Item 2)
299// ---------------------------------------------------------------------------
300
301/// Atomically swap the active `rustls::ServerConfig` in `store` with a new one
302/// derived from `creds`.
303///
304/// Used by [`spawn_tls_reloader_with_rustls_store`] on each detected file
305/// change.  Callers may also invoke this manually to rotate without involving
306/// the watcher (e.g. operator-driven rotation via an admin RPC).
307///
308/// # Errors
309///
310/// Returns [`HotReloadError::Rustls`] if the new credentials cannot be parsed
311/// into a valid `ServerConfig` (invalid PEM, mismatched key, …).  In that case
312/// the old config is left in place and the caller can decide whether to retry.
313pub fn swap_rustls_config(
314    store: &Arc<ArcSwap<rustls::ServerConfig>>,
315    creds: &TlsCreds,
316) -> Result<(), HotReloadError> {
317    let creds_ref = TlsCredsRef::new(&creds.cert_pem, &creds.key_pem);
318    let new_config =
319        build_rustls_config(&creds_ref).map_err(|e| HotReloadError::Rustls(e.to_string()))?;
320    store.store(Arc::new(new_config));
321    Ok(())
322}
323
324/// Spawn a TLS file-watcher that updates **both** the legacy [`TlsCreds`]
325/// store and a [`rustls::ServerConfig`] store.
326///
327/// The dual-store design preserves backward compatibility with code paths
328/// that still consume `TlsCreds` (e.g. for `tonic::transport::ServerTlsConfig`)
329/// while wiring the live-rotating
330/// [`amaters_net::tls_acceptor::LiveTlsAcceptor`] to the rustls store.
331///
332/// # Behaviour
333///
334/// On each detected change:
335/// 1. Reload PEM bytes into a new [`TlsCreds`].
336/// 2. Build a fresh `rustls::ServerConfig` via
337///    [`amaters_net::tls_acceptor::build_rustls_config`].
338/// 3. Atomically swap **both** stores.
339///
340/// If step 2 fails (invalid PEM), neither store is updated and an error is
341/// logged; the old config keeps serving traffic.
342///
343/// # Errors
344///
345/// Returns an error if the file-system watcher cannot be initialised.
346pub async fn spawn_tls_reloader_with_rustls_store(
347    cert_path: PathBuf,
348    key_path: PathBuf,
349    tls_creds: Arc<ArcSwap<TlsCreds>>,
350    rustls_store: Arc<ArcSwap<rustls::ServerConfig>>,
351) -> Result<(), HotReloadError> {
352    // Mirror the non-rustls watcher's plumbing.
353    let (tx, mut rx) = tokio::sync::mpsc::channel::<notify::Result<Event>>(16);
354
355    let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
356        let _ = tx.blocking_send(event);
357    })?;
358
359    let cert_dir = cert_path.parent().unwrap_or_else(|| Path::new("."));
360    watcher.watch(cert_dir, RecursiveMode::NonRecursive)?;
361
362    let cert_path_task = cert_path.clone();
363    let key_path_task = key_path.clone();
364
365    tokio::spawn(async move {
366        let _watcher = watcher;
367
368        while let Some(event) = rx.recv().await {
369            match event {
370                Ok(e) => {
371                    let relevant = e
372                        .paths
373                        .iter()
374                        .any(|p| p == &cert_path_task || p == &key_path_task);
375                    if !relevant {
376                        continue;
377                    }
378
379                    let new_creds = match TlsCreds::load_from_files(&cert_path_task, &key_path_task)
380                    {
381                        Ok(c) => c,
382                        Err(e) => {
383                            error!(
384                                "TLS reload failed (file read) — keeping existing credentials: {e}",
385                            );
386                            continue;
387                        }
388                    };
389
390                    // Build the new rustls config first; if it fails, neither store
391                    // is updated.
392                    if let Err(e) = swap_rustls_config(&rustls_store, &new_creds) {
393                        error!("TLS reload failed (rustls build) — keeping existing config: {e}",);
394                        continue;
395                    }
396
397                    tls_creds.store(Arc::new(new_creds));
398                    info!(
399                        "TLS credentials reloaded (legacy + rustls) from {:?}",
400                        cert_path_task
401                    );
402                }
403                Err(e) => {
404                    warn!("File-watcher error (TLS reloader): {e}");
405                }
406            }
407        }
408    });
409
410    Ok(())
411}
412
413// ---------------------------------------------------------------------------
414// Unit tests
415// ---------------------------------------------------------------------------
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use std::env;
421    use std::fs;
422
423    // -----------------------------------------------------------------------
424    // Helper: build a minimal ServerConfig with a given bind address
425    // -----------------------------------------------------------------------
426    fn make_config(bind: &str) -> ServerConfig {
427        let mut c = ServerConfig::default();
428        c.server.bind_address = bind.to_string();
429        c
430    }
431
432    // -----------------------------------------------------------------------
433    // config_diff tests (via ReloadableConfig)
434    // -----------------------------------------------------------------------
435
436    /// Reloading with an identical config produces no section updates.
437    #[test]
438    fn test_config_diff_empty_when_identical() {
439        use crate::config::diff;
440        let c = make_config("127.0.0.1:7878");
441        let d = diff(&c, &c);
442        assert!(
443            d.is_empty(),
444            "Diff of identical configs should be empty, got {:?}",
445            d
446        );
447    }
448
449    /// Reloading with a changed log level marks the Logging section as changed.
450    #[test]
451    fn test_config_diff_detects_log_level_change() {
452        use crate::config::ReloadableSection;
453        use crate::config::diff;
454        let old = make_config("127.0.0.1:7878");
455        let mut new = old.clone();
456        new.logging.level = "debug".to_string();
457        let d = diff(&old, &new);
458        assert!(
459            d.reloadable_changes.contains(&ReloadableSection::Logging),
460            "Expected Logging in reloadable_changes, got {:?}",
461            d.reloadable_changes
462        );
463    }
464
465    /// Changing max_connections marks the RateLimit section as changed.
466    #[test]
467    fn test_config_diff_detects_rate_limit_change() {
468        use crate::config::ReloadableSection;
469        use crate::config::diff;
470        let old = make_config("127.0.0.1:7878");
471        let mut new = old.clone();
472        new.server.max_connections = old.server.max_connections + 500;
473        let d = diff(&old, &new);
474        assert!(
475            d.reloadable_changes.contains(&ReloadableSection::RateLimit),
476            "Expected RateLimit in reloadable_changes, got {:?}",
477            d.reloadable_changes
478        );
479    }
480
481    /// Changing bind_address marks it as non-reloadable (requires restart).
482    #[test]
483    fn test_config_diff_non_reloadable_bind_address() {
484        use crate::config::{NonReloadableSection, diff};
485        let old = make_config("127.0.0.1:7878");
486        let new = make_config("127.0.0.1:9999");
487        let d = diff(&old, &new);
488        assert!(
489            d.non_reloadable_changes
490                .contains(&NonReloadableSection::BindAddress),
491            "Expected BindAddress in non_reloadable_changes, got {:?}",
492            d.non_reloadable_changes
493        );
494    }
495
496    // -----------------------------------------------------------------------
497    // ReloadableConfig + manual_reload round-trip
498    // -----------------------------------------------------------------------
499
500    /// Writing an updated config to disk and calling manual_reload applies
501    /// only reloadable changes.
502    #[test]
503    fn test_manual_reload_applies_log_level_change() {
504        let dir = env::temp_dir();
505        let path = dir.join("amaters_hot_reload_test_manual.toml");
506
507        // Write initial config.
508        let initial = make_config("127.0.0.1:7878");
509        initial.save_to_file(&path).expect("save initial config");
510
511        let rc = ReloadableConfig::new(initial.clone());
512        rc.set_config_path(path.clone());
513
514        // Modify log level in file.
515        let mut updated = initial.clone();
516        updated.logging.level = "warn".to_string();
517        updated.save_to_file(&path).expect("save updated config");
518
519        let report = rc.manual_reload().expect("manual_reload succeeded");
520        assert!(report.success, "Expected reload success: {:?}", report);
521
522        // Verify the live config has the new log level.
523        assert_eq!(
524            rc.snapshot().logging.level,
525            "warn",
526            "Log level should be updated to 'warn'"
527        );
528
529        fs::remove_file(&path).ok();
530    }
531
532    // -----------------------------------------------------------------------
533    // TlsCreds helpers
534    // -----------------------------------------------------------------------
535
536    /// load_from_files returns Io error for missing files.
537    #[test]
538    fn test_tls_creds_load_missing_file() {
539        let result = TlsCreds::load_from_files(
540            Path::new("/nonexistent/cert.pem"),
541            Path::new("/nonexistent/key.pem"),
542        );
543        assert!(result.is_err(), "Expected error for missing files");
544    }
545
546    /// load_from_files succeeds when both files exist.
547    #[test]
548    fn test_tls_creds_load_valid_files() {
549        let dir = env::temp_dir();
550        let cert = dir.join("amaters_hot_reload_test_cert.pem");
551        let key = dir.join("amaters_hot_reload_test_key.pem");
552
553        fs::write(
554            &cert,
555            b"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n",
556        )
557        .expect("write cert");
558        fs::write(
559            &key,
560            b"-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n",
561        )
562        .expect("write key");
563
564        let creds = TlsCreds::load_from_files(&cert, &key).expect("load creds");
565        assert!(!creds.cert_pem.is_empty());
566        assert!(!creds.key_pem.is_empty());
567
568        fs::remove_file(&cert).ok();
569        fs::remove_file(&key).ok();
570    }
571
572    /// ArcSwap correctly holds and swaps TlsCreds.
573    #[test]
574    fn test_tls_creds_arc_swap() {
575        let dir = env::temp_dir();
576        let cert = dir.join("amaters_arc_swap_cert.pem");
577        let key = dir.join("amaters_arc_swap_key.pem");
578
579        fs::write(&cert, b"cert_v1").expect("write cert");
580        fs::write(&key, b"key_v1").expect("write key");
581
582        let creds1 = TlsCreds::load_from_files(&cert, &key).expect("load v1");
583        let store: Arc<ArcSwap<TlsCreds>> = Arc::new(ArcSwap::from_pointee(creds1));
584
585        assert_eq!(store.load().cert_pem, b"cert_v1");
586
587        // Swap in new creds.
588        fs::write(&cert, b"cert_v2").expect("write cert v2");
589        fs::write(&key, b"key_v2").expect("write key v2");
590        let creds2 = TlsCreds::load_from_files(&cert, &key).expect("load v2");
591        store.store(Arc::new(creds2));
592
593        assert_eq!(store.load().cert_pem, b"cert_v2");
594
595        fs::remove_file(&cert).ok();
596        fs::remove_file(&key).ok();
597    }
598
599    // -----------------------------------------------------------------------
600    // build_server_tls_config helper
601    // -----------------------------------------------------------------------
602
603    /// build_server_tls_config succeeds when cert+key files exist.
604    /// Note: tonic will reject non-valid PEM at handshake time, not at build
605    /// time, so this test verifies the function does not return a file-IO error.
606    #[test]
607    fn test_build_server_tls_config_file_error() {
608        let result = build_server_tls_config(
609            Path::new("/nonexistent/cert.pem"),
610            Path::new("/nonexistent/key.pem"),
611        );
612        assert!(
613            matches!(result, Err(HotReloadError::Io(_))),
614            "Expected Io error, got {:?}",
615            result
616        );
617    }
618
619    /// `swap_rustls_config` rejects garbage creds with a `Rustls` error rather
620    /// than panicking.
621    #[test]
622    fn test_swap_rustls_config_rejects_invalid_pem() {
623        // Build an initial valid config from a tempfile so we have something
624        // in the store to begin with.
625        let dir = env::temp_dir();
626        let cert = dir.join(format!(
627            "amaters_swap_rustls_cert_{}.pem",
628            uuid::Uuid::new_v4()
629        ));
630        let key = dir.join(format!(
631            "amaters_swap_rustls_key_{}.pem",
632            uuid::Uuid::new_v4()
633        ));
634        // We cannot easily generate a real PEM here without rcgen; instead
635        // start with bytes that build_rustls_config will reject and verify
636        // the error variant.
637        fs::write(&cert, b"not-pem").expect("write cert");
638        fs::write(&key, b"not-pem").expect("write key");
639
640        let creds = TlsCreds::load_from_files(&cert, &key).expect("load creds");
641        // Seed the store with a placeholder ServerConfig built from a real
642        // self-signed cert via amaters_net's SelfSignedGenerator.  Avoiding
643        // that here keeps the test scope small — we exercise only the
644        // error path of swap_rustls_config which doesn't need a working
645        // initial config to verify the failure surface.
646        let placeholder = make_placeholder_server_config();
647        let store: Arc<ArcSwap<rustls::ServerConfig>> =
648            Arc::new(ArcSwap::from_pointee(placeholder));
649
650        let result = swap_rustls_config(&store, &creds);
651        assert!(
652            matches!(result, Err(HotReloadError::Rustls(_))),
653            "Expected Rustls error, got {:?}",
654            result
655        );
656
657        fs::remove_file(&cert).ok();
658        fs::remove_file(&key).ok();
659    }
660
661    /// `swap_rustls_config` with a real self-signed PEM pair succeeds.
662    #[test]
663    fn test_swap_rustls_config_accepts_valid_pem() {
664        let _ = rustls::crypto::ring::default_provider().install_default();
665        let (cert_pem, key_pem) = generate_pem_pair("swap.test");
666
667        let creds = TlsCreds { cert_pem, key_pem };
668        let placeholder = make_placeholder_server_config();
669        let store: Arc<ArcSwap<rustls::ServerConfig>> =
670            Arc::new(ArcSwap::from_pointee(placeholder));
671
672        swap_rustls_config(&store, &creds).expect("swap should succeed");
673        // The store now holds a non-placeholder config; we can't directly
674        // compare ServerConfig but `store.load()` returning a fresh `Arc`
675        // proves the swap happened.
676        let _ = store.load();
677    }
678
679    // -----------------------------------------------------------------------
680    // Helpers for the rustls swap tests
681    // -----------------------------------------------------------------------
682
683    /// Build a minimal placeholder `rustls::ServerConfig` suitable for use as
684    /// the initial value in an `ArcSwap` for tests that only verify swap
685    /// semantics (not actual TLS handshakes).
686    fn make_placeholder_server_config() -> rustls::ServerConfig {
687        let _ = rustls::crypto::ring::default_provider().install_default();
688        let (cert_pem, key_pem) = generate_pem_pair("placeholder.test");
689        let creds_ref = TlsCredsRef::new(&cert_pem, &key_pem);
690        build_rustls_config(&creds_ref).expect("placeholder rustls config")
691    }
692
693    /// Generate a self-signed cert PEM pair using amaters_net's SelfSignedGenerator.
694    fn generate_pem_pair(cn: &str) -> (Vec<u8>, Vec<u8>) {
695        use amaters_net::tls::SelfSignedGenerator;
696        use rustls::pki_types::PrivateKeyDer;
697        let generator = SelfSignedGenerator::new(cn)
698            .with_san(cn)
699            .with_san("localhost");
700        let (cert_der, key_der) = generator.generate().expect("generate cert");
701        let cert_pem = pem_encode("CERTIFICATE", cert_der.as_ref());
702        let key_pem = match key_der {
703            PrivateKeyDer::Pkcs8(k) => pem_encode("PRIVATE KEY", k.secret_pkcs8_der()),
704            PrivateKeyDer::Pkcs1(k) => pem_encode("RSA PRIVATE KEY", k.secret_pkcs1_der()),
705            PrivateKeyDer::Sec1(k) => pem_encode("EC PRIVATE KEY", k.secret_sec1_der()),
706            _ => panic!("unsupported key kind"),
707        };
708        (cert_pem, key_pem)
709    }
710
711    /// Minimal PEM encoder for tests.
712    fn pem_encode(label: &str, der: &[u8]) -> Vec<u8> {
713        let mut out = format!("-----BEGIN {label}-----\n").into_bytes();
714        let b64 = base64_encode_test(der);
715        for chunk in b64.as_bytes().chunks(64) {
716            out.extend_from_slice(chunk);
717            out.push(b'\n');
718        }
719        out.extend_from_slice(format!("-----END {label}-----\n").as_bytes());
720        out
721    }
722
723    /// Tiny base64 encoder for tests (RFC 4648 standard alphabet, padding).
724    fn base64_encode_test(data: &[u8]) -> String {
725        const ALPHABET: &[u8; 64] =
726            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
727        let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
728        let mut i = 0;
729        while i + 3 <= data.len() {
730            let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | (data[i + 2] as u32);
731            out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
732            out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
733            out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
734            out.push(ALPHABET[(n & 0x3f) as usize] as char);
735            i += 3;
736        }
737        let rem = data.len() - i;
738        if rem == 1 {
739            let n = (data[i] as u32) << 16;
740            out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
741            out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
742            out.push('=');
743            out.push('=');
744        } else if rem == 2 {
745            let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
746            out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
747            out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
748            out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
749            out.push('=');
750        }
751        out
752    }
753
754    // -----------------------------------------------------------------------
755    // SIGHUP integration test (manual / no real signal)
756    // -----------------------------------------------------------------------
757
758    /// Verify that spawn_config_reloader returns a live JoinHandle and that the
759    /// underlying ReloadableConfig mechanism works via manual_reload, without
760    /// actually sending SIGHUP (which is flaky in test environments).
761    #[tokio::test]
762    async fn test_spawn_config_reloader_returns_handle() {
763        let dir = env::temp_dir();
764        let path = dir.join("amaters_sighup_test_config.toml");
765
766        let initial = make_config("127.0.0.1:7878");
767        initial.save_to_file(&path).expect("save config");
768
769        let config = Arc::new(RwLock::new(initial.clone()));
770        let handle = spawn_config_reloader(path.clone(), config.clone()).await;
771
772        // The task must be running (not finished).
773        assert!(!handle.is_finished(), "Reloader task should be running");
774
775        // Abort the background task so the test exits cleanly.
776        handle.abort();
777
778        fs::remove_file(&path).ok();
779    }
780
781    // -----------------------------------------------------------------------
782    // SIGHUP integration test — #[ignore], requires real SIGHUP signal
783    // -----------------------------------------------------------------------
784
785    /// Integration test: send a real SIGHUP to the current process and verify
786    /// the config is reloaded.
787    ///
788    /// This test is marked `#[ignore]` because it sends a real UNIX signal and
789    /// is intended for manual execution only:
790    ///
791    /// ```sh
792    /// cargo test -p amaters-server test_sighup_reloads_config -- --ignored
793    /// ```
794    #[cfg(unix)]
795    #[tokio::test]
796    #[ignore = "Integration test — sends a real SIGHUP; run manually with --ignored"]
797    async fn test_sighup_reloads_config() {
798        use std::time::Duration;
799
800        let dir = env::temp_dir();
801        let path = dir.join("amaters_sighup_integration_test.toml");
802
803        let initial = make_config("127.0.0.1:7878");
804        initial.save_to_file(&path).expect("save config");
805
806        let config = Arc::new(RwLock::new(initial.clone()));
807        let handle = spawn_config_reloader(path.clone(), config.clone()).await;
808
809        // Allow the task to register the signal handler.
810        tokio::time::sleep(Duration::from_millis(50)).await;
811
812        // Modify the config file — change log level.
813        let mut updated = initial.clone();
814        updated.logging.level = "debug".to_string();
815        updated.save_to_file(&path).expect("save updated config");
816
817        // Send SIGHUP to self via `kill` utility (avoids needing the `libc` crate).
818        let pid = std::process::id();
819        let _ = std::process::Command::new("kill")
820            .args(["-HUP", &pid.to_string()])
821            .status()
822            .expect("failed to invoke kill command");
823
824        // Allow the handler to process the signal.
825        tokio::time::sleep(Duration::from_millis(200)).await;
826
827        assert_eq!(
828            config.read().logging.level,
829            "debug",
830            "Expected log level to be 'debug' after SIGHUP reload"
831        );
832
833        handle.abort();
834        fs::remove_file(&path).ok();
835    }
836}