From f0068962bfe4eb496c6024eac6a6ad0434b5eb6d Mon Sep 17 00:00:00 2001 From: Jeremy HERGAULT Date: Mon, 26 Oct 2026 16:53:24 +0100 Subject: [PATCH] feat: add metric for certificate expirations Signed-off-by: Jeremy HERGAULT --- prosa/examples/proc.rs | 14 ++ prosa/src/core/main.rs | 43 +++++ prosa/src/io.rs | 2 +- prosa/src/io/listener.rs | 6 +- prosa/src/io/ssl.rs | 146 ++++++++++++-- prosa_book/src/ch01-02-01-observability.md | 18 ++ prosa_utils/src/config/ssl.rs | 75 ++++++++ prosa_utils/src/config/ssl/openssl.rs | 209 ++++++++++++++++++++- 8 files changed, 486 insertions(+), 27 deletions(-) diff --git a/prosa/examples/proc.rs b/prosa/examples/proc.rs index 02527de..d2fdc3b 100644 --- a/prosa/examples/proc.rs +++ b/prosa/examples/proc.rs @@ -6,6 +6,7 @@ use prosa::core::proc::{Proc, ProcBusParam, ProcConfig, proc}; use prosa::core::settings::tracing::TelemetryFilter; use prosa::core::settings::{ProsaConfig, Settings, settings}; use prosa::event::pending::PendingMsgs; +use prosa::io::listener::ListenerSetting; use prosa::stub::adaptor::StubAsyncParotAdaptor; use prosa::stub::proc::{StubProc, StubSettings}; use prosa::tracing::{debug, info, warn}; @@ -16,6 +17,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::time; use tracing::metadata::LevelFilter; +use url::Url; #[derive(Default, Adaptor)] struct MyAdaptor {} @@ -235,6 +237,18 @@ async fn main() -> Result<(), Box> { ); Proc::::run(proc2)?; + // SSL listener serving a self signed certificate, exported in the `prosa_ssl_certificates` metric + let listener = ListenerSetting::new(Url::parse("https://localhost:4443")?, None) + .bind() + .await?; + tokio::spawn(async move { + loop { + if let Err(err) = listener.accept().await { + warn!("SSL listener can't accept a client: {err}"); + } + } + }); + // Wait on main task main.run().await; diff --git a/prosa/src/core/main.rs b/prosa/src/core/main.rs index 0158de1..c97b3bc 100644 --- a/prosa/src/core/main.rs +++ b/prosa/src/core/main.rs @@ -556,6 +556,14 @@ where }) .build(); + // Monitor SSL certificates expiration + #[cfg(feature = "openssl")] + self.meter + .i64_observable_gauge("prosa_ssl_certificates") + .with_description("Expiration date, in Unix seconds, of the SSL certificates loaded") + .with_callback(|observer| crate::io::ssl::observe_certificates(observer)) + .build(); + let mut proc_names = IntHashMap::with_hasher(BuildIntHasher::default()); // Monitor processors objects @@ -834,6 +842,41 @@ mod tests { }) } + #[cfg(all(feature = "prometheus", feature = "openssl"))] + #[tokio::test] + async fn exports_ssl_certificates_expiration() { + let settings = HealthSettings { + observability: Observability::default(), + }; + let (bus, main) = MainProc::::create(&settings, Some(1)); + let registry = bus.get_prometheus_registry().clone(); + let main_task = tokio::spawn(main.run()); + + let listener = crate::io::listener::ListenerSetting::new( + url::Url::parse("https://localhost:0").expect("Listener url is valid"), + None, + ) + .bind() + .await + .expect("listener should bind"); + wait_until(|| gauge_value(®istry, "prosa_ssl_certificates").is_some()).await; + let now = std::time::UNIX_EPOCH + .elapsed() + .expect("time should be after the epoch") + .as_secs() as f64; + assert!( + registry + .gather() + .into_iter() + .filter(|family| family.name() == "prosa_ssl_certificates") + .flat_map(|family| family.get_metric().to_vec()) + .any(|metric| metric.get_gauge().get_value() > now) + ); + + drop(listener); + main_task.abort(); + } + #[tokio::test] async fn readiness_tracks_required_processors_and_services() { let observability = yaml_serde::from_str( diff --git a/prosa/src/io.rs b/prosa/src/io.rs index ff9cbbb..d4df3fa 100644 --- a/prosa/src/io.rs +++ b/prosa/src/io.rs @@ -18,7 +18,7 @@ pub use prosa_utils::config::{ pub mod listener; pub mod pool; #[cfg(feature = "openssl")] -mod ssl; +pub(crate) mod ssl; pub mod stream; /// Trait to define ProSA IO. diff --git a/prosa/src/io/listener.rs b/prosa/src/io/listener.rs index 10be6a7..ab2989c 100644 --- a/prosa/src/io/listener.rs +++ b/prosa/src/io/listener.rs @@ -57,9 +57,10 @@ pub struct SslHandshaker { /// Acceptor holding the certificate served to the clients, shared by the clones #[cfg(feature = "openssl")] pub(super) acceptor: std::sync::Arc>, - /// Configuration and host the acceptor is built from, [`None`] when it was handed over built + /// Configuration, host and name of the listener the acceptor is built from, [`None`] when it + /// was handed over built #[cfg(feature = "openssl")] - pub(super) source: Option)>>, + pub(super) source: Option, String)>>, /// Timeout of the SSL handshake with a client pub(super) timeout: Duration, } @@ -572,6 +573,7 @@ impl ListenerSetting { SslHandshaker::build( self.ssl.clone().unwrap_or_default(), self.url.host_str().map(String::from), + self.get_safe_url().to_string(), ) .await .map(Some) diff --git a/prosa/src/io/ssl.rs b/prosa/src/io/ssl.rs index b8988b6..9d92c38 100644 --- a/prosa/src/io/ssl.rs +++ b/prosa/src/io/ssl.rs @@ -1,15 +1,17 @@ //! OpenSSL part of the streams and the listeners: the context they keep across connections, and //! the code that connects and handshakes with it. use std::{ + collections::BTreeMap, fmt, io, pin::Pin, sync::{ - Arc, PoisonError, RwLock, - atomic::{AtomicBool, Ordering}, + Arc, Mutex, PoisonError, RwLock, + atomic::{AtomicBool, AtomicU64, Ordering}, }, time::Duration, }; +use crate::otel::{KeyValue, metrics::AsyncInstrument}; use openssl::ssl::{SslAcceptor, SslConnector}; use prosa_utils::config::ssl::{SslConfig, SslContextBuild}; use tokio::{ @@ -25,9 +27,30 @@ use super::{ stream::{Stream, TargetSetting}, }; +/// Expiration date and attributes of a loaded certificate +type LoadedCertificate = (i64, Vec); + +/// Certificates loaded by every [`SslContextCache`], by cache id +static LOADED_CERTIFICATES: Mutex>> = + Mutex::new(BTreeMap::new()); +static NEXT_CACHE_ID: AtomicU64 = AtomicU64::new(0); + +/// Observe the expiration date of every certificate loaded in an SSL context +pub(crate) fn observe_certificates(observer: &dyn AsyncInstrument) { + for (not_after, attributes) in LOADED_CERTIFICATES + .lock() + .unwrap_or_else(PoisonError::into_inner) + .values() + .flatten() + { + observer.observe(*not_after, attributes); + } +} + /// SSL context kept across connections, with the generation of the [`SslConfig`] files it was /// built from pub(crate) struct SslContextCache { + id: u64, context: RwLock>, rebuilding: AtomicBool, } @@ -35,19 +58,28 @@ pub(crate) struct SslContextCache { impl Default for SslContextCache { fn default() -> Self { SslContextCache { + id: NEXT_CACHE_ID.fetch_add(1, Ordering::Relaxed), context: RwLock::new(None), rebuilding: AtomicBool::new(false), } } } +impl Drop for SslContextCache { + fn drop(&mut self) { + LOADED_CERTIFICATES + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(&self.id); + } +} + impl SslContextCache { /// Cache holding a context built elsewhere, never built again - pub(crate) fn with_context(context: C) -> Self { - SslContextCache { - context: RwLock::new(Some((context, 0))), - rebuilding: AtomicBool::new(false), - } + pub(crate) fn with_context(context: C, name: &str) -> Self { + let cache = SslContextCache::default(); + cache.store(context, 0, &SslConfig::default(), name); + cache } pub(crate) fn current(&self) -> Option<(C, u64)> { @@ -57,8 +89,18 @@ impl SslContextCache { .clone() } - pub(crate) fn store(&self, context: C, generation: u64) { + /// Keep the context, and the certificates it loaded for the metrics + pub(crate) fn store(&self, context: C, generation: u64, config: &SslConfig, name: &str) { + let certificates = context + .certificates(config) + .into_iter() + .map(|cert| (cert.not_after, cert.attributes(name))) + .collect(); *self.context.write().unwrap_or_else(PoisonError::into_inner) = Some((context, generation)); + LOADED_CERTIFICATES + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(self.id, certificates); } /// Context to connect with now. @@ -84,16 +126,16 @@ impl SslContextCache { return Ok(context); } - let (config, host) = (config.clone(), host.map(String::from)); + let (build_config, host) = (config.clone(), host.map(String::from)); let (context, generation) = tokio::task::spawn_blocking(move || { // Read before the files, so a change made while reading them is a new generation. // Watching them starts here, out of the runtime - let generation = config.generation(); - C::build(&config, host.as_deref()).map(|context| (context, generation)) + let generation = build_config.generation(); + C::build(&build_config, host.as_deref()).map(|context| (context, generation)) }) .await .map_err(std::io::Error::other)??; - self.store(context.clone(), generation); + self.store(context.clone(), generation, config, &name.to_string()); Ok(context) } @@ -102,7 +144,7 @@ impl SslContextCache { pub(crate) fn rebuild(&self, config: &SslConfig, host: Option<&str>, name: &str) { let generation = config.generation(); match C::build(config, host) { - Ok(context) => self.store(context, generation), + Ok(context) => self.store(context, generation, config, name), Err(err) => { log::warn!("Can't read the certificates of {name}, keep the previous ones: {err}"); if let Some((_, built)) = self @@ -321,7 +363,7 @@ impl SslHandshaker { /// source decides when to replace them. pub fn new(acceptor: SslAcceptor, timeout: Option) -> SslHandshaker { SslHandshaker { - acceptor: Arc::new(SslContextCache::with_context(acceptor)), + acceptor: Arc::new(SslContextCache::with_context(acceptor, "listener")), source: None, timeout: timeout.unwrap_or(StreamListener::DEFAULT_SSL_TIMEOUT), } @@ -331,11 +373,12 @@ impl SslHandshaker { pub(super) async fn build( ssl_config: SslConfig, host: Option, + name: String, ) -> Result { let handshaker = SslHandshaker { acceptor: Arc::default(), timeout: ssl_config.get_ssl_timeout(), - source: Some(Arc::new((ssl_config, host))), + source: Some(Arc::new((ssl_config, host, name))), }; handshaker.acceptor().await?; Ok(handshaker) @@ -345,10 +388,8 @@ impl SslHandshaker { pub(super) async fn acceptor(&self) -> Result { match &self.source { Some(source) => { - let (ssl_config, host) = source.as_ref(); - self.acceptor - .get(ssl_config, host.as_deref(), &"the listener") - .await + let (ssl_config, host, name) = source.as_ref(); + self.acceptor.get(ssl_config, host.as_deref(), name).await } None => self .acceptor @@ -442,6 +483,33 @@ mod tests { }; use prosa_utils::config::ssl::SslConfigContext as _; + /// Type, name and serial of the certificates a cache registered for the metrics + fn registered(cache: &SslContextCache) -> Option> { + let attribute = |attributes: &[KeyValue], key: &str| { + attributes + .iter() + .find(|kv| kv.key.as_str() == key) + .map(|kv| kv.value.to_string()) + .unwrap_or_default() + }; + LOADED_CERTIFICATES + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(&cache.id) + .map(|certificates| { + certificates + .iter() + .map(|(_, attributes)| { + ( + attribute(attributes, "type"), + attribute(attributes, "name"), + attribute(attributes, "serial"), + ) + }) + .collect() + }) + } + #[test] fn rebuild_flag_is_released_when_a_task_is_dropped_or_panics() { #[derive(Clone)] @@ -526,8 +594,20 @@ mod tests { false }; + // Certificates registered for the metrics + let cache = listener + .handshaker() + .expect("The listener should accept SSL connections") + .acceptor + .clone(); + let registered = || registered(&cache).expect("The certificate should be registered"); + let certificate = accept().await?; assert_eq!(served(), certificate); + let first = registered(); + assert_eq!(1, first.len()); + assert_eq!("certificate", first[0].0); + assert_eq!("https://localhost:0/", first[0].1); // Served straight away from the current context, and built again in the background for // the next clients @@ -535,16 +615,31 @@ mod tests { assert!(accept_until_renewed(&certificate, 100).await); let renewed = served(); assert_eq!(renewed, accept().await?); + let renewed_registered = registered(); + assert_ne!(first, renewed_registered); // A key caught half written keeps the context it has std::fs::write(&key_path, "half written")?; assert!(!accept_until_renewed(&renewed, 10).await); assert_eq!(renewed, accept().await?); + assert_eq!(renewed_registered, registered()); // And it's read again once written write_certificate(&cert_path, &key_path); assert!(accept_until_renewed(&renewed, 100).await); assert_eq!(served(), accept().await?); + assert_ne!(renewed_registered, registered()); + + // Unregistered with the listener + drop(listener); + let id = cache.id; + drop(cache); + assert!( + !LOADED_CERTIFICATES + .lock() + .unwrap_or_else(PoisonError::into_inner) + .contains_key(&id) + ); std::fs::remove_dir_all(dir) } @@ -553,6 +648,11 @@ mod tests { async fn listener_handed_an_acceptor_never_builds_it_again() -> io::Result<()> { let acceptor = SslConfig::default().init_tls_server_context(None)?.build(); let handshaker = SslHandshaker::new(acceptor, None); + assert_eq!( + Some(vec!["self_signed".to_string()]), + registered(&handshaker.acceptor) + .map(|certificates| certificates.into_iter().map(|(kind, ..)| kind).collect()) + ); let certificate = handshaker .acceptor() .await? @@ -649,6 +749,14 @@ mod tests { assert_eq!(first, connected(&clone).await?); assert_eq!(first, connected(&target).await?); + // A client without certificate registers the CAs of its store only (OpenSSL 3 lists them) + assert!( + registered(&target.ssl_context) + .expect("The target should register its certificates") + .iter() + .all(|(kind, ..)| kind == "store") + ); + // Built again once the trusted certificate is renewed, for the clones too write_certificate(&cert_path, &key_path); let renewed = ::openssl::x509::X509::from_pem(&std::fs::read(&cert_path)?)?.to_pem()?; diff --git a/prosa_book/src/ch01-02-01-observability.md b/prosa_book/src/ch01-02-01-observability.md index 1dde6e2..b24ed98 100644 --- a/prosa_book/src/ch01-02-01-observability.md +++ b/prosa_book/src/ch01-02-01-observability.md @@ -255,3 +255,21 @@ readinessProbe: See the [Kubernetes probe documentation](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/) for deployment-specific timing and failure thresholds. + +### SSL certificates + +With the `openssl` feature, the `prosa_ssl_certificates` gauge exposes every certificate loaded by the SSL listeners and targets. Its value is the certificate expiration date (`notAfter`) in Unix seconds. +A series is exported per certificate, with these attributes: + +- `type`: `certificate` for the certificate presented to the peer, `self_signed` for the one a listener signs when it has none configured, `chain` for the intermediate certificates of a PKCS12, and `store` for the trusted certificates of a `store` path or inline `certs` (the system store is not exported). +- `name`: the URL of the listener or the target, with its credentials masked. +- `subject_` and `issuer_`: an attribute per entry of the certificate subject and issuer, named by its lowercase short name (`subject_c`, `subject_o`, `subject_ou`, `subject_cn`, `issuer_cn`, ...). The values of a repeated entry are joined with `, `. The `issuer_` attributes are left out of self signed certificates (root CAs included), whose issuer is their subject. +- `serial`: the certificate serial number in hexadecimal. +- `encryption`: the public key algorithm, as `RSA-4096` or `ECDSA-prime256v1`. + +Certificates read again from their files replace the previous ones in the gauge, so the series follow certificate rotations. +To alert 30 days before a certificate expires: + +```promql +prosa_ssl_certificates{type!="self_signed"} - time() < 30 * 24 * 3600 +``` diff --git a/prosa_utils/src/config/ssl.rs b/prosa_utils/src/config/ssl.rs index 8f8476f..644176f 100644 --- a/prosa_utils/src/config/ssl.rs +++ b/prosa_utils/src/config/ssl.rs @@ -451,6 +451,81 @@ pub trait SslContextBuild: Clone + Send + Sync + 'static { /// Method to build the context from the files of the configuration. `host` is the name a /// server context is signed for when it has no certificate to serve fn build(config: &SslConfig, host: Option<&str>) -> io::Result; + + /// Method to describe the certificates loaded in the context built from `config` + fn certificates(&self, _config: &SslConfig) -> Vec { + Vec::new() + } +} + +/// Role of a certificate loaded in an SSL context +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CertificateKind { + /// Certificate presented to the peer + Certificate, + /// Certificate signed by a server that has none to present + SelfSigned, + /// Intermediate certificate sent along with the presented one + Chain, + /// Trusted certificate of a configured store + Store, +} + +impl CertificateKind { + /// Getter of the kind name + pub fn as_str(&self) -> &'static str { + match self { + CertificateKind::Certificate => "certificate", + CertificateKind::SelfSigned => "self_signed", + CertificateKind::Chain => "chain", + CertificateKind::Store => "store", + } + } +} + +/// Description of a certificate loaded in an SSL context +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CertificateInfo { + /// Role of the certificate + pub kind: CertificateKind, + /// Subject name entries, as `("CN", "prosa.example.com")` + pub subject: Vec<(String, String)>, + /// Issuer name entries, as `("CN", "Worldline CA")` + pub issuer: Vec<(String, String)>, + /// Serial number in hexadecimal + pub serial: String, + /// Public key algorithm, as `RSA-2048` or `ECDSA-prime256v1` + pub encryption: String, + /// Expiration date in Unix seconds + pub not_after: i64, +} + +#[cfg(feature = "config-observability")] +impl CertificateInfo { + /// Metric attributes of the certificate, loaded by the context `name` + pub fn attributes(&self, name: &str) -> Vec { + let mut attributes = vec![ + opentelemetry::KeyValue::new("type", self.kind.as_str()), + opentelemetry::KeyValue::new("name", name.to_string()), + opentelemetry::KeyValue::new("serial", self.serial.clone()), + opentelemetry::KeyValue::new("encryption", self.encryption.clone()), + ]; + // The issuer of a self signed certificate is its subject + let issuer: &[_] = if self.issuer == self.subject { + &[] + } else { + &self.issuer + }; + for (prefix, entries) in [("subject", self.subject.as_slice()), ("issuer", issuer)] { + attributes.extend(entries.iter().map(|(key, value)| { + opentelemetry::KeyValue::new( + format!("{prefix}_{}", key.to_lowercase()), + value.clone(), + ) + })); + } + attributes + } } #[cfg(feature = "config-openssl")] diff --git a/prosa_utils/src/config/ssl/openssl.rs b/prosa_utils/src/config/ssl/openssl.rs index c3ea8b7..20592f6 100644 --- a/prosa_utils/src/config/ssl/openssl.rs +++ b/prosa_utils/src/config/ssl/openssl.rs @@ -12,6 +12,7 @@ use std::{ net::IpAddr, num::TryFromIntError, ops::DerefMut, + sync::OnceLock, time, }; @@ -20,15 +21,16 @@ use openssl::{ asn1::{Asn1Integer, Asn1Time}, bn::{BigNum, MsbOption}, ec::{Asn1Flag, EcGroup, EcKey}, + ex_data::Index, hash::MessageDigest, nid::Nid, - pkey::PKey, + pkey::{Id, PKey}, ssl::{ - AlpnError, SslAcceptor, SslAcceptorBuilder, SslConnectorBuilder, SslContextBuilder, - SslFiletype, SslMethod, SslVerifyMode, + AlpnError, SslAcceptor, SslAcceptorBuilder, SslConnectorBuilder, SslContext, + SslContextBuilder, SslContextRef, SslFiletype, SslMethod, SslVerifyMode, }, x509::{ - X509, X509NameBuilder, + X509, X509NameBuilder, X509NameRef, X509Ref, extension::SubjectAlternativeName, store::{X509Store, X509StoreBuilder}, }, @@ -36,7 +38,10 @@ use openssl::{ use crate::config::{ ConfigError, os_country, - ssl::{SslConfig, SslConfigContext, SslContextBuild, SslStore, Store}, + ssl::{ + CertificateInfo, CertificateKind, SslConfig, SslConfigContext, SslContextBuild, SslStore, + Store, + }, }; impl SslStore for Store { @@ -210,6 +215,12 @@ impl SslStore for Store { } } +/// Index of the marker set on a context once a certificate is added to its extra chain +fn extra_chain_index() -> Option> { + static INDEX: OnceLock>> = OnceLock::new(); + *INDEX.get_or_init(|| SslContext::new_ex_index().ok()) +} + /// Method to init an SSL context for a socket pub(crate) fn init_openssl_context( config: &SslConfig, @@ -237,6 +248,9 @@ where if let Some(ca) = pkcs12.ca { for cert in ca { context_builder.add_extra_chain_cert(cert)?; + if let Some(index) = extra_chain_index() { + context_builder.set_ex_data(index, ()); + } } } } @@ -480,11 +494,119 @@ impl SslConfigContext for SslConfig { } } +/// Method to list the entries of an X509 name as `(short name, value)`, the values of a repeated +/// name joined. Entries of unknown names are left out +fn x509_name(name: &X509NameRef) -> Vec<(String, String)> { + let mut entries: Vec<(String, String)> = Vec::new(); + for entry in name.entries() { + let nid = entry.object().nid(); + let Some(key) = (nid != Nid::UNDEF).then(|| nid.short_name().ok()).flatten() else { + continue; + }; + let value = entry.data().to_string().unwrap_or_else(|_| "?".to_string()); + match entries.iter_mut().find(|(k, _)| k == key) { + Some((_, values)) => { + values.push_str(", "); + values.push_str(&value); + } + None => entries.push((key.to_string(), value)), + } + } + entries +} + +/// Method to name the public key algorithm of a certificate, as `RSA-2048` or `ECDSA-prime256v1` +fn key_encryption(cert: &X509Ref) -> String { + let Ok(key) = cert.public_key() else { + return "?".to_string(); + }; + match key.id() { + Id::RSA => format!("RSA-{}", key.bits()), + Id::EC => format!( + "ECDSA-{}", + key.ec_key() + .ok() + .and_then(|ec| ec.group().curve_name()) + .and_then(|nid| nid.short_name().ok()) + .unwrap_or("?") + ), + id => Nid::from_raw(id.as_raw()) + .short_name() + .unwrap_or("?") + .to_string(), + } +} + +/// Method to describe a certificate, [`None`] if one of its fields can't be read +fn certificate_info(kind: CertificateKind, cert: &X509Ref) -> Option { + let not_after = Asn1Time::from_unix(0).ok()?.diff(cert.not_after()).ok()?; + Some(CertificateInfo { + kind, + subject: x509_name(cert.subject_name()), + issuer: x509_name(cert.issuer_name()), + encryption: key_encryption(cert), + serial: cert + .serial_number() + .to_bn() + .ok()? + .to_hex_str() + .ok()? + .to_string(), + not_after: i64::from(not_after.days) * 86400 + i64::from(not_after.secs), + }) +} + +/// Method to describe the certificates of a context: the presented one, its chain, and the CAs of +/// a configured store. The system store is left out +fn context_certificates( + context: &SslContextRef, + config: &SslConfig, + is_server: bool, +) -> Vec { + let mut certificates = Vec::new(); + if let Some(cert) = context.certificate() { + // Same condition as the self signed certificate of `init_openssl_context` + let kind = if is_server && config.pkcs12.is_none() && config.key.is_none() { + CertificateKind::SelfSigned + } else { + CertificateKind::Certificate + }; + certificates.extend(certificate_info(kind, cert)); + } + + // OpenSSL has no chain until a certificate is added to it + if extra_chain_index().is_some_and(|index| context.ex_data(index).is_some()) { + certificates.extend( + context + .extra_chain_certs() + .iter() + .filter_map(|cert| certificate_info(CertificateKind::Chain, cert)), + ); + } + + #[cfg(ossl300)] + if matches!(config.store, Some(Store::File { .. } | Store::Cert { .. })) { + certificates.extend( + context + .cert_store() + .all_certificates() + .iter() + .filter_map(|cert| certificate_info(CertificateKind::Store, cert)), + ); + } + + certificates +} + impl SslContextBuild for openssl::ssl::SslConnector { fn build(config: &SslConfig, _host: Option<&str>) -> std::io::Result { let builder: openssl::ssl::SslConnectorBuilder = config.init_tls_client_context()?; Ok(builder.build()) } + + fn certificates(&self, config: &SslConfig) -> Vec { + context_certificates(self.context(), config, false) + } } impl SslContextBuild for openssl::ssl::SslAcceptor { @@ -494,6 +616,10 @@ impl SslContextBuild for openssl::ssl::SslAcceptor { .map(|builder: openssl::ssl::SslAcceptorBuilder| builder.build()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e)) } + + fn certificates(&self, config: &SslConfig) -> Vec { + context_certificates(self.context(), config, true) + } } #[cfg(test)] @@ -585,6 +711,79 @@ tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1 assert!(ssl_acceptor.context().certificate().is_some()); } + #[test] + fn describes_self_signed_certificate() -> Result<(), Box> { + let now = time::UNIX_EPOCH.elapsed()?.as_secs() as i64; + let config = SslConfig::default(); + let acceptor = ::build(&config, None)?; + let certificates = acceptor.certificates(&config); + assert_eq!(1, certificates.len()); + assert_eq!(CertificateKind::SelfSigned, certificates[0].kind); + assert_eq!( + Some(&("CN".to_string(), "ProSA".to_string())), + certificates[0].subject.last() + ); + assert_eq!(certificates[0].subject, certificates[0].issuer); + assert_eq!("ECDSA-prime256v1", certificates[0].encryption); + assert!((certificates[0].not_after - (now + 1461 * 86400)).abs() < 60); + + // No certificate presented by a client without one, nor its implicit system store + let connector = ::build(&config, None)?; + assert!(connector.certificates(&config).is_empty()); + Ok(()) + } + + #[test] + fn describes_certificate_and_store() -> Result<(), Box> { + let dir = std::env::temp_dir().join(format!( + "prosa-describe-certificates-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + fs::create_dir_all(&dir)?; + let signed = SslConfig::default().init_tls_server_context(None)?.build(); + let cert = dir.join("cert.pem"); + let key = dir.join("key.pem"); + fs::write( + &cert, + signed + .context() + .certificate() + .expect("certificate") + .to_pem()?, + )?; + fs::write( + &key, + signed + .context() + .private_key() + .expect("key") + .private_key_to_pem_pkcs8()?, + )?; + let mut config = SslConfig::new_cert_key( + cert.to_string_lossy().into_owned(), + key.to_string_lossy().into_owned(), + None, + ); + config.set_store(Store::File { + path: cert.to_string_lossy().into_owned(), + }); + let acceptor = ::build(&config, None)?; + let certificates = acceptor.certificates(&config); + fs::remove_dir_all(dir)?; + + assert_eq!(CertificateKind::Certificate, certificates[0].kind); + #[cfg(ossl300)] + { + assert_eq!(2, certificates.len()); + assert_eq!(CertificateKind::Store, certificates[1].kind); + assert_eq!(certificates[0].serial, certificates[1].serial); + } + Ok(()) + } + #[test] fn rejects_mismatched_certificate_and_key() -> Result<(), Box> { let dir = std::env::temp_dir().join(format!(