use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use rustls_pki_types::CertificateDer;
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::rustls::server::danger::ClientCertVerifier;
use tokio_rustls::rustls::server::WebPkiClientVerifier;
use tokio_rustls::rustls::{RootCertStore, ServerConfig};
use tokio_rustls::server::TlsStream;
use tokio_rustls::TlsAcceptor;
use crate::config::TlsConfig;
use crate::error::Result;
#[derive(Clone)]
pub struct TlsConfigSource {
inner: Arc<TlsConfigSourceInner>,
}
struct TlsConfigSourceInner {
current: ArcSwap<ServerConfig>,
origin: Option<TlsConfig>,
}
impl TlsConfigSource {
#[must_use]
pub fn from_server_config(server_config: Arc<ServerConfig>) -> Self {
Self {
inner: Arc::new(TlsConfigSourceInner {
current: ArcSwap::new(server_config),
origin: None,
}),
}
}
pub fn from_tls_config(tls_config: &TlsConfig) -> Result<Self> {
let server_config = load_server_config(tls_config)?;
Ok(Self {
inner: Arc::new(TlsConfigSourceInner {
current: ArcSwap::new(server_config),
origin: Some(tls_config.clone()),
}),
})
}
#[must_use]
pub fn load(&self) -> Arc<ServerConfig> {
self.inner.current.load_full()
}
#[must_use]
pub fn is_reloadable(&self) -> bool {
self.inner.origin.is_some()
}
#[must_use]
pub fn origin(&self) -> Option<&TlsConfig> {
self.inner.origin.as_ref()
}
#[must_use]
pub fn ptr_eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.inner, &other.inner)
}
pub fn reload(&self) -> Result<()> {
let Some(ref origin) = self.inner.origin else {
let err = crate::error::Error::Internal(
"TLS credentials cannot be reloaded: this source was built from an \
already-loaded ServerConfig and has no files to reread"
.to_string(),
);
tracing::error!("{}", err);
return Err(err);
};
match load_server_config(origin) {
Ok(server_config) => {
self.inner.current.store(server_config);
tracing::info!(
cert_path = %origin.cert_path.display(),
key_path = %origin.key_path.display(),
"TLS credentials reloaded; new handshakes use the new certificate"
);
Ok(())
}
Err(e) => {
tracing::error!(
cert_path = %origin.cert_path.display(),
key_path = %origin.key_path.display(),
error = %e,
"TLS credential reload failed; continuing to serve the previous \
certificate. New credentials will not take effect until a reload \
succeeds."
);
Err(e)
}
}
}
}
impl std::fmt::Debug for TlsConfigSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TlsConfigSource")
.field("reloadable", &self.is_reloadable())
.field("origin", &self.inner.origin)
.finish()
}
}
impl From<Arc<ServerConfig>> for TlsConfigSource {
fn from(server_config: Arc<ServerConfig>) -> Self {
Self::from_server_config(server_config)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum TlsListenerKind {
Http,
Grpc,
}
impl TlsListenerKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Grpc => "grpc",
}
}
}
impl std::fmt::Display for TlsListenerKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Default)]
pub struct TlsReloadHandle {
http: Option<TlsConfigSource>,
grpc: Option<TlsConfigSource>,
}
impl TlsReloadHandle {
#[must_use]
pub fn new(http: Option<TlsConfigSource>, grpc: Option<TlsConfigSource>) -> Self {
let http = http.filter(TlsConfigSource::is_reloadable);
let grpc = grpc
.filter(TlsConfigSource::is_reloadable)
.filter(|g| http.as_ref().is_none_or(|h| !h.ptr_eq(g)));
Self { http, grpc }
}
#[must_use]
pub fn http(&self) -> Option<&TlsConfigSource> {
self.http.as_ref()
}
#[must_use]
pub fn grpc(&self) -> Option<&TlsConfigSource> {
self.grpc.as_ref()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.http.is_none() && self.grpc.is_none()
}
#[must_use]
pub fn sources(&self) -> Vec<(TlsListenerKind, TlsConfigSource)> {
let mut out = Vec::with_capacity(2);
if let Some(ref http) = self.http {
out.push((TlsListenerKind::Http, http.clone()));
}
if let Some(ref grpc) = self.grpc {
out.push((TlsListenerKind::Grpc, grpc.clone()));
}
out
}
pub fn reload_all(&self) -> Vec<(TlsListenerKind, Result<()>)> {
self.sources()
.into_iter()
.map(|(listener, source)| {
let result = source.reload();
match result {
Ok(()) => tracing::info!(
listener = listener.as_str(),
"TLS credentials rotated for the {listener} listener"
),
Err(ref e) => tracing::error!(
listener = listener.as_str(),
error = %e,
"TLS reload failed for the {listener} listener; \
it continues to serve its previous certificate"
),
}
(listener, result)
})
.collect()
}
}
impl std::fmt::Debug for TlsReloadHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TlsReloadHandle")
.field("http", &self.http.is_some())
.field("grpc", &self.grpc.is_some())
.finish()
}
}
fn fingerprint_credentials(tls_config: &TlsConfig) -> std::io::Result<u64> {
let mut hasher = DefaultHasher::new();
for path in [
Some(&tls_config.cert_path),
Some(&tls_config.key_path),
tls_config.client_ca_path.as_ref(),
]
.into_iter()
.flatten()
{
path.hash(&mut hasher);
let bytes = std::fs::read(path)?;
bytes.len().hash(&mut hasher);
bytes.hash(&mut hasher);
}
Ok(hasher.finish())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ReloadTick {
Unchanged,
Reloaded { fingerprint: u64 },
ReadFailed,
ReloadFailed,
}
pub(crate) fn reload_tick(
source: &TlsConfigSource,
listener: TlsListenerKind,
last_seen: Option<u64>,
) -> ReloadTick {
let Some(origin) = source.origin() else {
return ReloadTick::Unchanged;
};
let fingerprint = match fingerprint_credentials(origin) {
Ok(fingerprint) => fingerprint,
Err(e) => {
tracing::warn!(
listener = listener.as_str(),
cert_path = %origin.cert_path.display(),
error = %e,
"could not read TLS credential files while polling for rotation; \
continuing to serve the current certificate and retrying next tick"
);
return ReloadTick::ReadFailed;
}
};
if last_seen == Some(fingerprint) {
return ReloadTick::Unchanged;
}
match source.reload() {
Ok(()) => {
tracing::info!(
listener = listener.as_str(),
cert_path = %origin.cert_path.display(),
"TLS credential files changed on disk; the {listener} listener now \
serves the new certificate"
);
ReloadTick::Reloaded { fingerprint }
}
Err(_) => ReloadTick::ReloadFailed,
}
}
pub(crate) fn spawn_reload_poll(
source: TlsConfigSource,
listener: TlsListenerKind,
period: Duration,
) -> tokio::task::JoinHandle<()> {
let mut last_seen = source
.origin()
.and_then(|o| fingerprint_credentials(o).ok());
tracing::info!(
listener = listener.as_str(),
interval_secs = period.as_secs(),
"polling TLS credential files for rotation"
);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(period);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
ticker.tick().await;
loop {
ticker.tick().await;
if let ReloadTick::Reloaded { fingerprint } = reload_tick(&source, listener, last_seen)
{
last_seen = Some(fingerprint);
}
}
})
}
#[cfg(unix)]
pub(crate) fn spawn_sighup_reload(handle: TlsReloadHandle) -> Result<tokio::task::JoinHandle<()>> {
use tokio::signal::unix::{signal, SignalKind};
let mut hangup = signal(SignalKind::hangup()).map_err(|e| {
crate::error::Error::Internal(format!(
"failed to install the SIGHUP handler for TLS credential reload: {e}"
))
})?;
tracing::info!("SIGHUP will reload TLS credentials for every configured listener");
Ok(tokio::spawn(async move {
while hangup.recv().await.is_some() {
tracing::info!("received SIGHUP; reloading TLS credentials");
let _ = handle.reload_all();
}
}))
}
#[derive(Default)]
pub(crate) struct TlsReloadTasks {
handles: Vec<tokio::task::JoinHandle<()>>,
}
impl TlsReloadTasks {
pub(crate) fn push(&mut self, handle: tokio::task::JoinHandle<()>) {
self.handles.push(handle);
}
}
impl Drop for TlsReloadTasks {
fn drop(&mut self) {
for handle in &self.handles {
handle.abort();
}
}
}
pub(crate) fn validate_reload_interval(
tls_cfg: &TlsConfig,
section: &str,
) -> Result<Option<Duration>> {
match tls_cfg.reload_interval_secs {
None => Ok(None),
Some(0) => Err(crate::error::Error::Internal(format!(
"{section} sets reload_interval_secs = 0, which would poll the certificate \
files without pause. Omit the field to disable polling, or set a positive \
number of seconds."
))),
Some(secs) => Ok(Some(Duration::from_secs(secs))),
}
}
pub(crate) fn warn_if_reload_config_is_unusable(
resolved: Option<&TlsConfigSource>,
tls_cfg: &TlsConfig,
section: &str,
) {
let requested = tls_cfg.reload_interval_secs.is_some() || tls_cfg.reload_on_sighup;
if !requested {
return;
}
if resolved.is_some_and(|s| !s.is_reloadable()) {
tracing::warn!(
"{section} configures TLS credential reloading, but the credentials for this \
listener were supplied directly to the builder as an already-loaded \
ServerConfig. There are no files to reread, so the reload triggers are \
ignored. Use with_tls_config_source with a source built from a TlsConfig, \
or let the [tls] section load the files, to make rotation possible."
);
}
}
pub(crate) fn install_reload_triggers(
handle: &TlsReloadHandle,
http_interval: Option<Duration>,
grpc_interval: Option<Duration>,
sighup: bool,
) -> TlsReloadTasks {
let mut tasks = TlsReloadTasks::default();
if let (Some(source), Some(period)) = (handle.http().cloned(), http_interval) {
tasks.push(spawn_reload_poll(source, TlsListenerKind::Http, period));
}
if let (Some(source), Some(period)) = (handle.grpc().cloned(), grpc_interval) {
tasks.push(spawn_reload_poll(source, TlsListenerKind::Grpc, period));
}
if sighup {
if handle.is_empty() {
tracing::warn!(
"TLS reload_on_sighup is enabled, but no listener has reloadable \
credentials, so SIGHUP will not reload anything"
);
} else {
#[cfg(unix)]
match spawn_sighup_reload(handle.clone()) {
Ok(task) => tasks.push(task),
Err(e) => tracing::error!(
error = %e,
"could not install the SIGHUP TLS reload handler; \
other reload triggers are unaffected"
),
}
#[cfg(not(unix))]
tracing::warn!(
"TLS reload_on_sighup is enabled, but signals are not supported on \
this platform; use reload_interval_secs or \
ServiceBuilder::with_tls_reload instead"
);
}
}
tasks
}
pub struct TlsListener {
tcp: TcpListener,
config_source: TlsConfigSource,
}
impl TlsListener {
pub fn new(tcp: TcpListener, server_config: Arc<ServerConfig>) -> Self {
Self::with_config_source(tcp, TlsConfigSource::from_server_config(server_config))
}
pub fn with_config_source(tcp: TcpListener, config_source: TlsConfigSource) -> Self {
Self { tcp, config_source }
}
#[must_use]
pub fn config_source(&self) -> &TlsConfigSource {
&self.config_source
}
}
impl axum::serve::Listener for TlsListener {
type Io = TlsStream<TcpStream>;
type Addr = SocketAddr;
fn accept(&mut self) -> impl std::future::Future<Output = (Self::Io, Self::Addr)> + Send {
let config_source = self.config_source.clone();
let tcp = &mut self.tcp;
async move {
loop {
let (stream, addr) = match TcpListener::accept(tcp).await {
Ok((stream, addr)) => (stream, addr),
Err(e) => {
tracing::error!("TCP accept error: {}", e);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue;
}
};
let acceptor = TlsAcceptor::from(config_source.load());
match acceptor.accept(stream).await {
Ok(tls_stream) => return (tls_stream, addr),
Err(e) => {
tracing::warn!("TLS handshake failed from {}: {}", addr, e);
continue;
}
}
}
}
}
fn local_addr(&self) -> io::Result<Self::Addr> {
self.tcp.local_addr()
}
}
pub fn load_client_ca_roots(path: &Path) -> Result<RootCertStore> {
load_root_store(path, "client CA")
}
pub(crate) fn load_root_store(path: &Path, role: &str) -> Result<RootCertStore> {
use rustls_pki_types::pem::PemObject;
let ca_certs: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(path)
.map_err(|e| {
crate::error::Error::Internal(format!(
"Failed to open {} file '{}': {}",
role,
path.display(),
e
))
})?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| {
crate::error::Error::Internal(format!(
"Failed to parse {} certificates from '{}': {}",
role,
path.display(),
e
))
})?;
if ca_certs.is_empty() {
return Err(crate::error::Error::Internal(format!(
"The {} file '{}' contains no certificates",
role,
path.display()
)));
}
let mut roots = RootCertStore::empty();
for cert in ca_certs {
roots.add(cert).map_err(|e| {
crate::error::Error::Internal(format!(
"Failed to add {} certificate from '{}' to trust store: {}",
role,
path.display(),
e
))
})?;
}
Ok(roots)
}
pub fn build_client_verifier(
roots: RootCertStore,
optional: bool,
) -> Result<Arc<dyn ClientCertVerifier>> {
crate::crypto::ensure_default_crypto_provider();
let mut builder = WebPkiClientVerifier::builder(Arc::new(roots));
if optional {
builder = builder.allow_unauthenticated();
}
builder.build().map_err(|e| {
crate::error::Error::Internal(format!(
"Failed to build client certificate verifier: {}",
e
))
})
}
pub fn load_server_config(tls_config: &TlsConfig) -> Result<Arc<ServerConfig>> {
use rustls_pki_types::pem::PemObject;
use rustls_pki_types::PrivateKeyDer;
use tokio_rustls::rustls;
let cert_chain: Vec<rustls::pki_types::CertificateDer<'static>> =
CertificateDer::pem_file_iter(&tls_config.cert_path)
.map_err(|e| {
crate::error::Error::Internal(format!(
"Failed to open TLS cert file '{}': {}",
tls_config.cert_path.display(),
e
))
})?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| {
crate::error::Error::Internal(format!("Failed to parse TLS certificates: {}", e))
})?;
if cert_chain.is_empty() {
return Err(crate::error::Error::Internal(
"TLS cert file contains no certificates".to_string(),
));
}
let key = PrivateKeyDer::from_pem_file(&tls_config.key_path).map_err(|e| {
crate::error::Error::Internal(format!(
"Failed to parse TLS private key from '{}': {}",
tls_config.key_path.display(),
e
))
})?;
crate::crypto::ensure_default_crypto_provider();
let builder = ServerConfig::builder();
let config = match tls_config.client_ca_path {
Some(ref ca_path) => {
let roots = load_client_ca_roots(ca_path)?;
let verifier = build_client_verifier(roots, tls_config.client_auth_optional)?;
builder.with_client_cert_verifier(verifier)
}
None => builder.with_no_client_auth(),
}
.with_single_cert(cert_chain, key)
.map_err(|e| {
crate::error::Error::Internal(format!("Failed to build TLS server config: {}", e))
})?;
Ok(Arc::new(config))
}
#[derive(Clone, Debug)]
pub struct PeerCertificates(Arc<Vec<CertificateDer<'static>>>);
impl PeerCertificates {
#[must_use]
pub fn as_slice(&self) -> &[CertificateDer<'static>] {
&self.0
}
#[must_use]
pub fn leaf(&self) -> &CertificateDer<'static> {
&self.0[0]
}
}
#[derive(Clone, Debug)]
pub struct TlsConnectInfo {
remote_addr: SocketAddr,
peer_certificates: Option<PeerCertificates>,
}
impl TlsConnectInfo {
#[must_use]
pub fn remote_addr(&self) -> SocketAddr {
self.remote_addr
}
#[must_use]
pub fn peer_certificates(&self) -> Option<&PeerCertificates> {
self.peer_certificates.as_ref()
}
#[must_use]
pub fn is_mutually_authenticated(&self) -> bool {
self.peer_certificates.is_some()
}
}
impl axum::extract::connect_info::Connected<axum::serve::IncomingStream<'_, TlsListener>>
for TlsConnectInfo
{
fn connect_info(stream: axum::serve::IncomingStream<'_, TlsListener>) -> Self {
let remote_addr = *stream.remote_addr();
let (_, connection) = stream.io().get_ref();
let peer_certificates = connection
.peer_certificates()
.filter(|chain| !chain.is_empty())
.map(|chain| PeerCertificates(Arc::new(chain.to_vec())));
Self {
remote_addr,
peer_certificates,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
struct TestCert {
cert_pem: String,
key_pem: String,
der: CertificateDer<'static>,
}
fn generate_cert(name: &str) -> TestCert {
let certified = rcgen::generate_simple_self_signed(vec![name.to_string()])
.expect("self-signed cert generation");
TestCert {
cert_pem: certified.cert.pem(),
key_pem: certified.signing_key.serialize_pem(),
der: certified.cert.der().clone(),
}
}
fn write_temp(contents: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().expect("temp file");
file.write_all(contents.as_bytes()).expect("write temp");
file.flush().expect("flush temp");
file
}
#[test]
fn load_client_ca_roots_reads_a_valid_bundle() {
let ca = generate_cert("test-ca");
let file = write_temp(&ca.cert_pem);
let roots = load_client_ca_roots(file.path()).expect("valid CA bundle must load");
assert_eq!(
roots.len(),
1,
"the single CA certificate must become one trust anchor"
);
}
#[test]
fn load_client_ca_roots_rejects_a_missing_file() {
let err = load_client_ca_roots(Path::new("/nonexistent/ca.pem"))
.expect_err("a missing CA file must be an error, not an empty trust store");
assert!(
err.to_string().contains("Failed to open client CA file"),
"error must name the failure to open the file: {err}"
);
}
#[test]
fn load_client_ca_roots_rejects_a_file_without_certificates() {
let file = write_temp("# no certificates here\n");
let err = load_client_ca_roots(file.path())
.expect_err("a CA file with no certificates must be an error");
assert!(
err.to_string().contains("contains no certificates"),
"error must explain that the bundle is empty: {err}"
);
}
#[test]
fn build_client_verifier_supports_required_and_optional_modes() {
let ca = generate_cert("test-ca");
let file = write_temp(&ca.cert_pem);
let required_roots = load_client_ca_roots(file.path()).expect("roots");
build_client_verifier(required_roots, false)
.expect("a verifier requiring client auth must build");
let optional_roots = load_client_ca_roots(file.path()).expect("roots");
build_client_verifier(optional_roots, true)
.expect("a verifier allowing unauthenticated clients must build");
}
#[test]
fn load_server_config_without_client_ca_requests_no_client_auth() {
let server = generate_cert("localhost");
let cert_file = write_temp(&server.cert_pem);
let key_file = write_temp(&server.key_pem);
let config = TlsConfig {
enabled: true,
cert_path: cert_file.path().to_path_buf(),
key_path: key_file.path().to_path_buf(),
client_ca_path: None,
client_auth_optional: false,
reload_interval_secs: None,
reload_on_sighup: false,
};
load_server_config(&config).expect("server-only TLS config must build");
}
#[test]
fn load_server_config_with_client_ca_builds_a_mutual_tls_config() {
let server = generate_cert("localhost");
let ca = generate_cert("client-ca");
let cert_file = write_temp(&server.cert_pem);
let key_file = write_temp(&server.key_pem);
let ca_file = write_temp(&ca.cert_pem);
let config = TlsConfig {
enabled: true,
cert_path: cert_file.path().to_path_buf(),
key_path: key_file.path().to_path_buf(),
client_ca_path: Some(ca_file.path().to_path_buf()),
client_auth_optional: true,
reload_interval_secs: None,
reload_on_sighup: false,
};
load_server_config(&config).expect("mutual-TLS config must build");
}
#[test]
fn load_server_config_surfaces_an_invalid_client_ca() {
let server = generate_cert("localhost");
let cert_file = write_temp(&server.cert_pem);
let key_file = write_temp(&server.key_pem);
let config = TlsConfig {
enabled: true,
cert_path: cert_file.path().to_path_buf(),
key_path: key_file.path().to_path_buf(),
client_ca_path: Some(PathBuf::from("/nonexistent/client-ca.pem")),
client_auth_optional: false,
reload_interval_secs: None,
reload_on_sighup: false,
};
load_server_config(&config)
.expect_err("an unreadable client CA must fail the whole config build");
}
fn write_credentials(dir: &Path, cert: &TestCert) -> TlsConfig {
let cert_path = dir.join("server.pem");
let key_path = dir.join("server.key");
std::fs::write(&cert_path, &cert.cert_pem).expect("write cert");
std::fs::write(&key_path, &cert.key_pem).expect("write key");
TlsConfig {
enabled: true,
cert_path,
key_path,
client_ca_path: None,
client_auth_optional: false,
reload_interval_secs: None,
reload_on_sighup: false,
}
}
#[test]
fn static_source_serves_the_config_it_was_built_from() {
let server = generate_cert("localhost");
let cert_file = write_temp(&server.cert_pem);
let key_file = write_temp(&server.key_pem);
let config = TlsConfig {
enabled: true,
cert_path: cert_file.path().to_path_buf(),
key_path: key_file.path().to_path_buf(),
client_ca_path: None,
client_auth_optional: false,
reload_interval_secs: None,
reload_on_sighup: false,
};
let server_config = load_server_config(&config).expect("config builds");
let source = TlsConfigSource::from_server_config(server_config.clone());
assert!(
Arc::ptr_eq(&source.load(), &server_config),
"a static source must hand back exactly the config it was given"
);
assert!(
!source.is_reloadable(),
"a source with no file origin is not reloadable"
);
assert!(source.origin().is_none());
}
#[test]
fn reload_on_a_static_source_is_an_error_and_keeps_the_config() {
let server = generate_cert("localhost");
let cert_file = write_temp(&server.cert_pem);
let key_file = write_temp(&server.key_pem);
let config = TlsConfig {
enabled: true,
cert_path: cert_file.path().to_path_buf(),
key_path: key_file.path().to_path_buf(),
client_ca_path: None,
client_auth_optional: false,
reload_interval_secs: None,
reload_on_sighup: false,
};
let server_config = load_server_config(&config).expect("config builds");
let source = TlsConfigSource::from_server_config(server_config.clone());
let err = source
.reload()
.expect_err("a static source has no files to reread");
assert!(
err.to_string().contains("cannot be reloaded"),
"the error must say the source is not reloadable: {err}"
);
assert!(
Arc::ptr_eq(&source.load(), &server_config),
"a rejected reload must leave the served config untouched"
);
}
#[test]
fn successful_reload_swaps_in_the_new_credentials() {
let dir = tempfile::tempdir().expect("temp dir");
let first = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &first);
let source = TlsConfigSource::from_tls_config(&tls_config).expect("initial load");
assert!(source.is_reloadable(), "a file-backed source is reloadable");
let before = source.load();
let second = generate_cert("localhost");
std::fs::write(&tls_config.cert_path, &second.cert_pem).expect("rewrite cert");
std::fs::write(&tls_config.key_path, &second.key_pem).expect("rewrite key");
source
.reload()
.expect("rereading valid credentials must succeed");
assert!(
!Arc::ptr_eq(&source.load(), &before),
"a successful reload must install a newly loaded config"
);
}
#[test]
fn failed_reload_preserves_the_last_good_credentials() {
let dir = tempfile::tempdir().expect("temp dir");
let good = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &good);
let source = TlsConfigSource::from_tls_config(&tls_config).expect("initial load");
let last_good = source.load();
std::fs::write(
&tls_config.cert_path,
"-----BEGIN CERTIFICATE-----\ntruncated",
)
.expect("corrupt cert");
let err = source
.reload()
.expect_err("an unparseable certificate must fail the reload");
assert!(
Arc::ptr_eq(&source.load(), &last_good),
"a failed reload must keep serving the last-good config, not drop it"
);
assert!(
!err.to_string().is_empty(),
"the failure must be reported to the caller: {err}"
);
let replacement = generate_cert("localhost");
std::fs::write(&tls_config.cert_path, &replacement.cert_pem).expect("rewrite cert");
std::fs::write(&tls_config.key_path, &replacement.key_pem).expect("rewrite key");
source.reload().expect("a later valid reload must succeed");
assert!(
!Arc::ptr_eq(&source.load(), &last_good),
"the recovered reload must install the new config"
);
}
#[test]
fn clones_of_a_source_observe_the_same_reload() {
let dir = tempfile::tempdir().expect("temp dir");
let first = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &first);
let source = TlsConfigSource::from_tls_config(&tls_config).expect("initial load");
let listener_side = source.clone();
let before = listener_side.load();
let second = generate_cert("localhost");
std::fs::write(&tls_config.cert_path, &second.cert_pem).expect("rewrite cert");
std::fs::write(&tls_config.key_path, &second.key_pem).expect("rewrite key");
source.reload().expect("reload");
assert!(
Arc::ptr_eq(&listener_side.load(), &source.load()),
"every clone must see the same installed config"
);
assert!(
!Arc::ptr_eq(&listener_side.load(), &before),
"the clone held by the listener must see the rotation"
);
}
#[test]
fn poll_reloads_when_the_certificate_files_change() {
let dir = tempfile::tempdir().expect("temp dir");
let first = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &first);
let source = TlsConfigSource::from_tls_config(&tls_config).expect("initial load");
let baseline = fingerprint_credentials(&tls_config).expect("baseline fingerprint");
let before = source.load();
let second = generate_cert("localhost");
std::fs::write(&tls_config.cert_path, &second.cert_pem).expect("rewrite cert");
std::fs::write(&tls_config.key_path, &second.key_pem).expect("rewrite key");
let tick = reload_tick(&source, TlsListenerKind::Http, Some(baseline));
let ReloadTick::Reloaded { fingerprint } = tick else {
panic!("rewritten credentials must be detected and installed, got {tick:?}");
};
assert_ne!(
fingerprint, baseline,
"the new fingerprint must differ from the one that triggered the reload"
);
assert!(
!Arc::ptr_eq(&source.load(), &before),
"the reloaded config must be the one the listener now serves"
);
}
#[test]
fn poll_does_not_reload_when_the_files_are_untouched() {
let dir = tempfile::tempdir().expect("temp dir");
let cert = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &cert);
let source = TlsConfigSource::from_tls_config(&tls_config).expect("initial load");
let baseline = fingerprint_credentials(&tls_config).expect("baseline fingerprint");
let before = source.load();
let tick = reload_tick(&source, TlsListenerKind::Http, Some(baseline));
assert_eq!(
tick,
ReloadTick::Unchanged,
"unchanged files must not cost a reload on every tick"
);
assert!(
Arc::ptr_eq(&source.load(), &before),
"an unchanged tick must not swap the served config"
);
}
#[test]
fn poll_survives_a_half_written_certificate_and_recovers() {
let dir = tempfile::tempdir().expect("temp dir");
let good = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &good);
let source = TlsConfigSource::from_tls_config(&tls_config).expect("initial load");
let baseline = fingerprint_credentials(&tls_config).expect("baseline fingerprint");
let last_good = source.load();
std::fs::write(
&tls_config.cert_path,
"-----BEGIN CERTIFICATE-----\ntruncated",
)
.expect("write partial cert");
let tick = reload_tick(&source, TlsListenerKind::Http, Some(baseline));
assert_eq!(
tick,
ReloadTick::ReloadFailed,
"an unparseable certificate must fail the tick, not the task"
);
assert!(
Arc::ptr_eq(&source.load(), &last_good),
"a failed tick must keep serving the last-good credentials"
);
let replacement = generate_cert("localhost");
std::fs::write(&tls_config.cert_path, &replacement.cert_pem).expect("finish cert");
std::fs::write(&tls_config.key_path, &replacement.key_pem).expect("finish key");
let tick = reload_tick(&source, TlsListenerKind::Http, Some(baseline));
assert!(
matches!(tick, ReloadTick::Reloaded { .. }),
"the next tick must retry and succeed, got {tick:?}"
);
assert!(
!Arc::ptr_eq(&source.load(), &last_good),
"the recovered tick must install the completed credentials"
);
}
#[test]
fn poll_reports_a_read_failure_without_touching_the_served_config() {
let dir = tempfile::tempdir().expect("temp dir");
let cert = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &cert);
let source = TlsConfigSource::from_tls_config(&tls_config).expect("initial load");
let baseline = fingerprint_credentials(&tls_config).expect("baseline fingerprint");
let last_good = source.load();
std::fs::remove_file(&tls_config.cert_path).expect("remove cert");
let tick = reload_tick(&source, TlsListenerKind::Http, Some(baseline));
assert_eq!(
tick,
ReloadTick::ReadFailed,
"an unreadable file must be reported as a read failure"
);
assert!(
Arc::ptr_eq(&source.load(), &last_good),
"a read failure must never disturb the credentials already serving"
);
}
#[test]
fn poll_on_a_static_source_does_nothing() {
let server = generate_cert("localhost");
let cert_file = write_temp(&server.cert_pem);
let key_file = write_temp(&server.key_pem);
let config = TlsConfig {
enabled: true,
cert_path: cert_file.path().to_path_buf(),
key_path: key_file.path().to_path_buf(),
client_ca_path: None,
client_auth_optional: false,
reload_interval_secs: None,
reload_on_sighup: false,
};
let source =
TlsConfigSource::from_server_config(load_server_config(&config).expect("config"));
assert_eq!(
reload_tick(&source, TlsListenerKind::Http, None),
ReloadTick::Unchanged,
"a source with no files must not be reported as a failure every tick"
);
}
#[test]
fn rewriting_identical_bytes_is_not_a_rotation() {
let dir = tempfile::tempdir().expect("temp dir");
let cert = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &cert);
let source = TlsConfigSource::from_tls_config(&tls_config).expect("initial load");
let baseline = fingerprint_credentials(&tls_config).expect("baseline fingerprint");
let before = source.load();
std::fs::write(&tls_config.cert_path, &cert.cert_pem).expect("rewrite cert");
std::fs::write(&tls_config.key_path, &cert.key_pem).expect("rewrite key");
assert_eq!(
fingerprint_credentials(&tls_config).expect("fingerprint"),
baseline,
"identical contents must fingerprint identically however recently written"
);
assert_eq!(
reload_tick(&source, TlsListenerKind::Http, Some(baseline)),
ReloadTick::Unchanged,
"a touched-but-unchanged file must not be mistaken for a rotation"
);
assert!(Arc::ptr_eq(&source.load(), &before));
}
#[test]
fn fingerprint_distinguishes_same_length_contents() {
let dir = tempfile::tempdir().expect("temp dir");
let cert = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &cert);
let before = fingerprint_credentials(&tls_config).expect("fingerprint");
let mut bytes = std::fs::read(&tls_config.cert_path).expect("read cert");
let last = bytes.len() - 1;
bytes[last] ^= 0xff;
std::fs::write(&tls_config.cert_path, &bytes).expect("rewrite cert");
assert_ne!(
before,
fingerprint_credentials(&tls_config).expect("fingerprint"),
"a same-length change must still change the fingerprint"
);
}
#[test]
fn fingerprint_covers_the_client_ca_bundle() {
let dir = tempfile::tempdir().expect("temp dir");
let server = generate_cert("localhost");
let mut tls_config = write_credentials(dir.path(), &server);
let ca_path = dir.path().join("client-ca.pem");
std::fs::write(&ca_path, generate_cert("ca-one").cert_pem).expect("write ca");
tls_config.client_ca_path = Some(ca_path.clone());
let before = fingerprint_credentials(&tls_config).expect("fingerprint");
std::fs::write(&ca_path, generate_cert("ca-two").cert_pem).expect("rewrite ca");
assert_ne!(
before,
fingerprint_credentials(&tls_config).expect("fingerprint"),
"a changed client-CA bundle must be detected as a rotation"
);
}
#[test]
fn handle_keeps_only_sources_a_reload_can_act_on() {
let dir = tempfile::tempdir().expect("temp dir");
let cert = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &cert);
let reloadable = TlsConfigSource::from_tls_config(&tls_config).expect("load");
let static_source =
TlsConfigSource::from_server_config(load_server_config(&tls_config).expect("config"));
let handle = TlsReloadHandle::new(Some(reloadable), Some(static_source));
assert!(handle.http().is_some(), "the file-backed source is kept");
assert!(
handle.grpc().is_none(),
"a static source has no files to reread and must not be handed out"
);
assert!(!handle.is_empty());
}
#[test]
fn handle_is_empty_when_every_source_is_static() {
let dir = tempfile::tempdir().expect("temp dir");
let cert = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &cert);
let static_source =
TlsConfigSource::from_server_config(load_server_config(&tls_config).expect("config"));
let handle = TlsReloadHandle::new(Some(static_source), None);
assert!(
handle.is_empty(),
"a handle over only static sources must report that it can do nothing, \
so callers can warn instead of silently never reloading"
);
assert!(handle.reload_all().is_empty());
}
#[test]
fn handle_does_not_list_an_inherited_grpc_source_twice() {
let dir = tempfile::tempdir().expect("temp dir");
let cert = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &cert);
let shared = TlsConfigSource::from_tls_config(&tls_config).expect("load");
let handle = TlsReloadHandle::new(Some(shared.clone()), Some(shared));
assert!(
handle.grpc().is_none(),
"the inherited source is not listed"
);
assert_eq!(
handle.sources().len(),
1,
"one set of credentials must produce one reload"
);
}
#[test]
fn handle_lists_genuinely_separate_grpc_credentials() {
let http_dir = tempfile::tempdir().expect("temp dir");
let grpc_dir = tempfile::tempdir().expect("temp dir");
let http_config = write_credentials(http_dir.path(), &generate_cert("localhost"));
let grpc_config = write_credentials(grpc_dir.path(), &generate_cert("grpc.internal"));
let handle = TlsReloadHandle::new(
Some(TlsConfigSource::from_tls_config(&http_config).expect("load")),
Some(TlsConfigSource::from_tls_config(&grpc_config).expect("load")),
);
assert_eq!(
handle.sources().len(),
2,
"two independently-configured listeners must both be reloadable"
);
}
#[test]
fn reload_all_rotates_every_listener_and_reports_each_outcome() {
let http_dir = tempfile::tempdir().expect("temp dir");
let grpc_dir = tempfile::tempdir().expect("temp dir");
let http_config = write_credentials(http_dir.path(), &generate_cert("localhost"));
let grpc_config = write_credentials(grpc_dir.path(), &generate_cert("grpc.internal"));
let http_source = TlsConfigSource::from_tls_config(&http_config).expect("load");
let grpc_source = TlsConfigSource::from_tls_config(&grpc_config).expect("load");
let handle = TlsReloadHandle::new(Some(http_source.clone()), Some(grpc_source.clone()));
let http_before = http_source.load();
let grpc_before = grpc_source.load();
let rotated = generate_cert("localhost");
std::fs::write(&http_config.cert_path, &rotated.cert_pem).expect("rewrite cert");
std::fs::write(&http_config.key_path, &rotated.key_pem).expect("rewrite key");
std::fs::write(&grpc_config.cert_path, "-----BEGIN CERTIFICATE-----\nbad")
.expect("corrupt cert");
let outcomes = handle.reload_all();
assert_eq!(outcomes.len(), 2, "every listener must be reported on");
let http_result = outcomes
.iter()
.find(|(listener, _)| *listener == TlsListenerKind::Http)
.map(|(_, result)| result.is_ok());
let grpc_result = outcomes
.iter()
.find(|(listener, _)| *listener == TlsListenerKind::Grpc)
.map(|(_, result)| result.is_ok());
assert_eq!(http_result, Some(true), "the valid rotation must succeed");
assert_eq!(
grpc_result,
Some(false),
"the broken rotation must be reported, not swallowed by its sibling"
);
assert!(
!Arc::ptr_eq(&http_source.load(), &http_before),
"the listener whose rotation succeeded must serve the new certificate"
);
assert!(
Arc::ptr_eq(&grpc_source.load(), &grpc_before),
"the listener whose rotation failed must keep its last-good certificate"
);
}
#[test]
fn a_zero_poll_interval_is_rejected() {
let dir = tempfile::tempdir().expect("temp dir");
let mut tls_config = write_credentials(dir.path(), &generate_cert("localhost"));
tls_config.reload_interval_secs = Some(0);
let err = validate_reload_interval(&tls_config, "[tls]")
.expect_err("zero would poll without pause and must be refused");
let message = err.to_string();
assert!(
message.contains("reload_interval_secs = 0"),
"the error must name the setting: {message}"
);
assert!(
message.contains("[tls]"),
"the error must name the section so an operator knows which to fix: {message}"
);
}
#[test]
fn an_absent_or_positive_poll_interval_is_accepted() {
let dir = tempfile::tempdir().expect("temp dir");
let mut tls_config = write_credentials(dir.path(), &generate_cert("localhost"));
assert_eq!(
validate_reload_interval(&tls_config, "[tls]").expect("absent is valid"),
None,
"omitting the field is how polling is disabled"
);
tls_config.reload_interval_secs = Some(30);
assert_eq!(
validate_reload_interval(&tls_config, "[tls]").expect("positive is valid"),
Some(Duration::from_secs(30))
);
}
#[test]
fn reload_config_on_a_static_source_is_detected_as_unusable() {
let dir = tempfile::tempdir().expect("temp dir");
let mut tls_config = write_credentials(dir.path(), &generate_cert("localhost"));
tls_config.reload_interval_secs = Some(30);
let static_source =
TlsConfigSource::from_server_config(load_server_config(&tls_config).expect("config"));
let file_source = TlsConfigSource::from_tls_config(&tls_config).expect("load");
assert!(
!static_source.is_reloadable(),
"an injected ServerConfig has no files to reread"
);
assert!(file_source.is_reloadable());
warn_if_reload_config_is_unusable(Some(&static_source), &tls_config, "[tls]");
warn_if_reload_config_is_unusable(Some(&file_source), &tls_config, "[tls]");
tls_config.reload_interval_secs = None;
tls_config.reload_on_sighup = false;
warn_if_reload_config_is_unusable(Some(&static_source), &tls_config, "[tls]");
}
#[tokio::test]
async fn install_reload_triggers_arms_a_single_listener_path() {
let dir = tempfile::tempdir().expect("temp dir");
let tls_config = write_credentials(dir.path(), &generate_cert("localhost"));
let source = TlsConfigSource::from_tls_config(&tls_config).expect("load");
let handle = TlsReloadHandle::new(Some(source), None);
let tasks = install_reload_triggers(&handle, Some(Duration::from_secs(30)), None, false);
assert_eq!(
tasks.handles.len(),
1,
"a polled single-listener path must arm exactly one poll task"
);
}
#[tokio::test]
async fn install_reload_triggers_spawns_nothing_when_unconfigured() {
let dir = tempfile::tempdir().expect("temp dir");
let tls_config = write_credentials(dir.path(), &generate_cert("localhost"));
let source = TlsConfigSource::from_tls_config(&tls_config).expect("load");
let handle = TlsReloadHandle::new(Some(source), None);
let tasks = install_reload_triggers(&handle, None, None, false);
assert!(tasks.handles.is_empty());
}
#[tokio::test]
async fn dropping_the_task_guard_aborts_the_triggers() {
let dir = tempfile::tempdir().expect("temp dir");
let tls_config = write_credentials(dir.path(), &generate_cert("localhost"));
let source = TlsConfigSource::from_tls_config(&tls_config).expect("load");
let handle = TlsReloadHandle::new(Some(source), None);
let tasks = install_reload_triggers(&handle, Some(Duration::from_secs(30)), None, false);
let spawned: Vec<_> = tasks
.handles
.iter()
.map(tokio::task::JoinHandle::abort_handle)
.collect();
assert_eq!(spawned.len(), 1);
drop(tasks);
tokio::task::yield_now().await;
assert!(
spawned[0].is_finished(),
"the poll task must not outlive the guard that owns it"
);
}
#[test]
fn listener_kind_names_are_stable_for_logs() {
assert_eq!(TlsListenerKind::Http.as_str(), "http");
assert_eq!(TlsListenerKind::Grpc.as_str(), "grpc");
assert_eq!(TlsListenerKind::Grpc.to_string(), "grpc");
}
#[test]
fn handle_debug_does_not_leak_key_material() {
let dir = tempfile::tempdir().expect("temp dir");
let cert = generate_cert("localhost");
let tls_config = write_credentials(dir.path(), &cert);
let source = TlsConfigSource::from_tls_config(&tls_config).expect("load");
let handle = TlsReloadHandle::new(Some(source), None);
let rendered = format!("{handle:?}");
assert!(rendered.contains("http: true"));
assert!(
!rendered.contains("BEGIN"),
"Debug must not render PEM material: {rendered}"
);
}
#[test]
fn tls_connect_info_reports_absence_of_client_cert() {
let info = TlsConnectInfo {
remote_addr: "127.0.0.1:8443".parse().expect("addr"),
peer_certificates: None,
};
assert!(
!info.is_mutually_authenticated(),
"a connection without a client cert is not mutually authenticated"
);
assert!(info.peer_certificates().is_none());
assert_eq!(info.remote_addr(), "127.0.0.1:8443".parse().expect("addr"));
}
#[test]
fn tls_connect_info_exposes_the_verified_chain() {
let leaf = generate_cert("client-leaf");
let chain = PeerCertificates(Arc::new(vec![leaf.der.clone()]));
let info = TlsConnectInfo {
remote_addr: "10.0.0.5:9000".parse().expect("addr"),
peer_certificates: Some(chain),
};
assert!(info.is_mutually_authenticated());
let certs = info.peer_certificates().expect("chain present");
assert_eq!(certs.as_slice().len(), 1);
assert_eq!(certs.leaf(), &leaf.der, "the leaf must be the first cert");
}
}