use std::fs::File;
use std::io::BufReader;
use std::sync::Arc;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::WebPkiClientVerifier;
use rustls::{RootCertStore, ServerConfig};
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlsParams {
pub cert_path: String,
pub key_path: String,
pub client_ca_path: Option<String>,
}
impl TlsParams {
pub fn mtls(&self) -> bool {
self.client_ca_path.is_some()
}
}
pub fn tls_params_from_env() -> AppResult<Option<TlsParams>> {
let env = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty());
resolve_tls_params(
env("NOETL_TLS_CERT"),
env("NOETL_TLS_KEY"),
env("NOETL_TLS_CLIENT_CA"),
)
}
pub fn resolve_tls_params(
cert: Option<String>,
key: Option<String>,
client_ca: Option<String>,
) -> AppResult<Option<TlsParams>> {
match (cert, key) {
(Some(cert_path), Some(key_path)) => Ok(Some(TlsParams {
cert_path,
key_path,
client_ca_path: client_ca,
})),
(None, None) => Ok(None),
_ => Err(AppError::Config(
"TLS misconfigured: set both NOETL_TLS_CERT and NOETL_TLS_KEY (or neither)".to_string(),
)),
}
}
pub fn build_server_config(params: &TlsParams) -> AppResult<ServerConfig> {
let _ = rustls::crypto::ring::default_provider().install_default();
let certs = load_certs(¶ms.cert_path)?;
let key = load_key(¶ms.key_path)?;
let builder = ServerConfig::builder();
let config = match ¶ms.client_ca_path {
Some(ca) => {
let mut roots = RootCertStore::empty();
for cert in load_certs(ca)? {
roots.add(cert).map_err(|e| {
AppError::Config(format!("TLS: client CA '{ca}' not a valid cert: {e}"))
})?;
}
let verifier = WebPkiClientVerifier::builder(Arc::new(roots))
.build()
.map_err(|e| AppError::Config(format!("TLS: client-cert verifier: {e}")))?;
builder.with_client_cert_verifier(verifier)
}
None => builder.with_no_client_auth(),
};
config
.with_single_cert(certs, key)
.map_err(|e| AppError::Config(format!("TLS: server cert/key: {e}")))
}
fn load_certs(path: &str) -> AppResult<Vec<CertificateDer<'static>>> {
let file =
File::open(path).map_err(|e| AppError::Config(format!("TLS: open '{path}': {e}")))?;
let mut reader = BufReader::new(file);
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Config(format!("TLS: reading certs '{path}': {e}")))?;
if certs.is_empty() {
return Err(AppError::Config(format!(
"TLS: no certificates in '{path}'"
)));
}
Ok(certs)
}
fn load_key(path: &str) -> AppResult<PrivateKeyDer<'static>> {
let file =
File::open(path).map_err(|e| AppError::Config(format!("TLS: open '{path}': {e}")))?;
let mut reader = BufReader::new(file);
rustls_pemfile::private_key(&mut reader)
.map_err(|e| AppError::Config(format!("TLS: reading key '{path}': {e}")))?
.ok_or_else(|| AppError::Config(format!("TLS: no private key in '{path}'")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_none_when_unset() {
assert!(resolve_tls_params(None, None, None).unwrap().is_none());
}
#[test]
fn resolve_https_without_mtls() {
let p = resolve_tls_params(Some("c.pem".into()), Some("k.pem".into()), None)
.unwrap()
.unwrap();
assert_eq!(p.cert_path, "c.pem");
assert_eq!(p.key_path, "k.pem");
assert!(!p.mtls());
}
#[test]
fn resolve_mtls_when_client_ca_set() {
let p = resolve_tls_params(
Some("c.pem".into()),
Some("k.pem".into()),
Some("ca.pem".into()),
)
.unwrap()
.unwrap();
assert!(p.mtls());
assert_eq!(p.client_ca_path.as_deref(), Some("ca.pem"));
}
#[test]
fn resolve_rejects_partial_cert_key() {
assert!(resolve_tls_params(Some("c.pem".into()), None, None).is_err());
assert!(resolve_tls_params(None, Some("k.pem".into()), None).is_err());
}
#[test]
fn build_config_errors_on_missing_cert_file() {
let params = TlsParams {
cert_path: "/nonexistent/cert.pem".into(),
key_path: "/nonexistent/key.pem".into(),
client_ca_path: None,
};
let err = build_server_config(¶ms).unwrap_err();
assert!(format!("{err:?}").contains("open"), "got: {err:?}");
}
}