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}