//! TLS certificate probing, connect to a host, inspect the leaf cert, track expiry. use std::sync::Arc; use tokio::net::TcpStream; use tokio_rustls::TlsConnector; use tokio_rustls::rustls; use tracing::instrument; use crate::config::TlsConfig; use crate::types::TlsStatus; /// Trust stores we probe against. Both are checked on every run: they answer /// different questions and can legitimately disagree, so reporting only one /// would hide a real failure mode. /// /// - [`TrustStore::Webpki`] is the bundled Mozilla root set. It answers "does /// this chain validate against the public web PKI?", independent of whatever /// the monitoring host happens to trust. /// - [`TrustStore::Platform`] is the OS trust store. It answers "does this chain /// validate for a client on this machine?", which additionally reflects /// enterprise roots, admin-installed CAs, and OS-level distrust decisions. /// /// A chain trusted by one and rejected by the other is exactly the kind of drift /// worth alerting on, so neither result is derived from the other. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TrustStore { Webpki, Platform, } /// The crypto provider backing both probes. `main` installs ring as the process /// default; fall back to constructing it directly so the checks still work if /// that install is ever removed or reordered. fn crypto_provider() -> Arc { rustls::crypto::CryptoProvider::get_default() .cloned() .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider())) } /// Build a client config pinned to one trust store. fn client_config(store: TrustStore) -> Result { match store { // Shared with every outbound HTTP probe (see crate::tls) so the HTTP // reachability verdict and the cert-expiry verdict use one root set. TrustStore::Webpki => Ok(crate::tls::webpki_client_config()), TrustStore::Platform => { let verifier = rustls_platform_verifier::Verifier::new(crypto_provider()) .map_err(|e| format!("platform verifier unavailable: {e}"))?; Ok(rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(verifier)) .with_no_client_auth()) } } } /// Connect and handshake against one trust store, returning the peer chain. /// /// Each probe opens its own connection: a rustls handshake consumes the stream, /// and the verifier is fixed when the config is built, so the two stores cannot /// share one. async fn probe( addr: &str, server_name: rustls_pki_types::ServerName<'static>, store: TrustStore, ) -> Result>, String> { let tls_config = client_config(store)?; let tcp = match tokio::time::timeout(std::time::Duration::from_secs(10), TcpStream::connect(addr)) .await { Ok(Ok(stream)) => stream, Ok(Err(e)) => return Err(format!("TCP connect failed: {e}")), Err(_) => return Err("TCP connect timed out".to_string()), }; let connector = TlsConnector::from(Arc::new(tls_config)); let tls_stream = match tokio::time::timeout( std::time::Duration::from_secs(10), connector.connect(server_name, tcp), ) .await { Ok(Ok(stream)) => stream, Ok(Err(e)) => return Err(format!("TLS handshake failed: {e}")), Err(_) => return Err("TLS handshake timed out".to_string()), }; let (_io, client_conn) = tls_stream.into_inner(); match client_conn.peer_certificates() { Some(certs) if !certs.is_empty() => { Ok(certs.iter().map(|c| c.clone().into_owned()).collect()) } _ => Err("no peer certificates".to_string()), } } /// Connect to host:port and inspect the leaf cert, verifying the chain against /// both the bundled web-PKI roots and the OS trust store. See [`TrustStore`]. #[instrument(skip_all)] pub async fn check_tls(target_name: &str, config: &TlsConfig) -> TlsStatus { let checked_at = chrono::Utc::now().to_rfc3339(); let addr = format!("{}:{}", config.host, config.port); let server_name = match rustls_pki_types::ServerName::try_from(config.host.clone()) { Ok(name) => name, Err(e) => { return tls_error( target_name, config, &checked_at, &format!("invalid server name: {e}"), ); } }; let (webpki_result, platform_result) = tokio::join!( probe(&addr, server_name.clone(), TrustStore::Webpki), probe(&addr, server_name, TrustStore::Platform), ); // Cert details come from whichever probe completed a handshake. Prefer the // web-PKI chain so the reported fields stay stable across hosts, but fall // back to the platform chain, a cert trusted only by a private root still // has an expiry worth tracking. let certs = webpki_result .as_ref() .ok() .or(platform_result.as_ref().ok()); let Some(certs) = certs else { // Both failed. Surface both reasons rather than just the first. let webpki_err = webpki_result .as_ref() .err() .map_or("unknown", String::as_str); let platform_err = platform_result .as_ref() .err() .map_or("unknown", String::as_str); let mut status = tls_error( target_name, config, &checked_at, &format!("web PKI: {webpki_err}; platform: {platform_err}"), ); status.webpki_error = webpki_result.err(); status.platform_error = platform_result.err(); return status; }; let mut status = parse_leaf_cert(target_name, config, &checked_at, certs[0].as_ref()); status.webpki_trusted = webpki_result.is_ok(); status.platform_trusted = platform_result.is_ok(); status.webpki_error = webpki_result.err(); status.platform_error = platform_result.err(); // `valid` keeps its established meaning for the health contract: trusted by // the public web PKI and not expired. status.valid = status.valid && status.webpki_trusted; status } /// Parse DER-encoded leaf cert bytes into a TlsStatus. pub fn parse_leaf_cert( target_name: &str, config: &TlsConfig, checked_at: &str, der_bytes: &[u8], ) -> TlsStatus { use x509_parser::prelude::FromDer; let (_, cert) = match x509_parser::prelude::X509Certificate::from_der(der_bytes) { Ok(result) => result, Err(e) => { return tls_error( target_name, config, checked_at, &format!("cert parse error: {e}"), ); } }; let not_before_ts = cert.validity().not_before.timestamp(); let not_after_ts = cert.validity().not_after.timestamp(); let now = chrono::Utc::now(); let not_after_chrono = chrono::DateTime::from_timestamp(not_after_ts, 0).unwrap_or(now); let not_before_chrono = chrono::DateTime::from_timestamp(not_before_ts, 0).unwrap_or(now); // Use date-level comparison for consistent day boundary behavior. // A certificate expiring today (same calendar day in UTC) gets 0 days remaining // and is treated as expired. This avoids time-of-day inconsistencies where // num_days() might return 0 for both "expires later today" and "expired earlier today". let today = now.date_naive(); let expiry_date = not_after_chrono.date_naive(); let days_remaining = (expiry_date - today).num_days(); let subject = cert.subject().to_string(); let issuer = cert.issuer().to_string(); TlsStatus { target: target_name.to_string(), host: config.host.clone(), port: config.port, valid: days_remaining > 0, days_remaining, not_before: not_before_chrono.to_rfc3339(), not_after: not_after_chrono.to_rfc3339(), subject, issuer, checked_at: checked_at.to_string(), error: None, // Trust results are filled in by `check_tls`, which owns both probes. // Callers parsing a bare cert (tests, cached DER) get the conservative // "not established" default rather than a fabricated pass. webpki_trusted: false, platform_trusted: false, webpki_error: None, platform_error: None, } } fn tls_error(target_name: &str, config: &TlsConfig, checked_at: &str, error: &str) -> TlsStatus { TlsStatus { target: target_name.to_string(), host: config.host.clone(), port: config.port, valid: false, days_remaining: 0, not_before: String::new(), not_after: String::new(), subject: String::new(), issuer: String::new(), checked_at: checked_at.to_string(), error: Some(error.to_string()), webpki_trusted: false, platform_trusted: false, webpki_error: None, platform_error: None, } } #[cfg(test)] mod tests { use super::*; /// Live-network check that both trust stores are actually exercised and /// report independently. Ignored by default so the suite stays offline; run /// with `cargo test -- --ignored dual_trust` when touching the probe logic. #[tokio::test] #[ignore = "requires network access"] async fn dual_trust_probes_agree_on_public_and_reject_bad_certs() { let cfg = |host: &str| TlsConfig { host: host.to_string(), port: 443, warn_days: 30, }; // A normal public cert: trusted by the bundled web PKI and by the OS. let good = check_tls("good", &cfg("makenot.work")).await; assert!( good.webpki_trusted, "web PKI should trust makenot.work: {:?}", good.webpki_error ); assert!( good.platform_trusted, "OS store should trust makenot.work: {:?}", good.platform_error ); assert!(good.valid); assert!(good.days_remaining > 0); // An expired cert must be rejected by both, and each must say why // rather than inheriting the other's verdict. let expired = check_tls("expired", &cfg("expired.badssl.com")).await; assert!(!expired.webpki_trusted); assert!(!expired.platform_trusted); assert!(expired.webpki_error.is_some()); assert!(expired.platform_error.is_some()); assert!(!expired.valid); // A self-signed cert chains to no public or OS root. let self_signed = check_tls("self-signed", &cfg("self-signed.badssl.com")).await; assert!(!self_signed.webpki_trusted); assert!(!self_signed.platform_trusted); assert!(!self_signed.valid); } fn test_config() -> TlsConfig { TlsConfig { host: "example.com".to_string(), port: 443, warn_days: 14, } } #[test] fn parse_leaf_cert_with_invalid_der() { let config = test_config(); let result = parse_leaf_cert("test", &config, "2026-03-11T00:00:00Z", b"not-a-cert"); assert!(!result.valid); assert!(result.error.as_ref().unwrap().contains("cert parse error")); } #[test] fn tls_error_populates_all_fields() { let config = test_config(); let result = tls_error( "test", &config, "2026-03-11T00:00:00Z", "connection refused", ); assert_eq!(result.target, "test"); assert_eq!(result.host, "example.com"); assert_eq!(result.port, 443); assert!(!result.valid); assert_eq!(result.days_remaining, 0); assert_eq!(result.error.as_deref(), Some("connection refused")); } /// Generate a self-signed DER certificate with the given validity period. fn make_cert_der( not_before: chrono::DateTime, not_after: chrono::DateTime, ) -> Vec { use rcgen::{CertificateParams, KeyPair}; let mut params = CertificateParams::new(vec!["example.com".to_string()]).unwrap(); // Convert chrono::DateTime -> std::time::SystemTime -> time::OffsetDateTime let nb_sys = std::time::UNIX_EPOCH + std::time::Duration::from_secs(not_before.timestamp() as u64); let na_sys = std::time::UNIX_EPOCH + std::time::Duration::from_secs(not_after.timestamp() as u64); params.not_before = nb_sys.into(); params.not_after = na_sys.into(); let key = KeyPair::generate().unwrap(); let cert = params.self_signed(&key).unwrap(); cert.der().to_vec() } #[test] fn tls_cert_expiring_today_is_zero_days_and_invalid() { let config = test_config(); let now = chrono::Utc::now(); let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); let today_end = now.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc(); // Cert that started yesterday, expires today let not_before = today_start - chrono::Duration::days(1); let der = make_cert_der(not_before, today_end); let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der); assert_eq!( result.days_remaining, 0, "cert expiring today should show 0 days" ); assert!(!result.valid, "cert expiring today should be invalid"); assert!(result.error.is_none()); } #[test] fn tls_cert_expiring_tomorrow_has_one_day_remaining() { let config = test_config(); let now = chrono::Utc::now(); let tomorrow = (now + chrono::Duration::days(1)) .date_naive() .and_hms_opt(23, 59, 59) .unwrap() .and_utc(); let not_before = now - chrono::Duration::days(30); let der = make_cert_der(not_before, tomorrow); let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der); assert_eq!( result.days_remaining, 1, "cert expiring tomorrow should show 1 day" ); assert!(result.valid, "cert expiring tomorrow should be valid"); } #[test] fn tls_cert_already_expired_is_invalid() { let config = test_config(); let now = chrono::Utc::now(); let yesterday = (now - chrono::Duration::days(1)) .date_naive() .and_hms_opt(23, 59, 59) .unwrap() .and_utc(); let not_before = now - chrono::Duration::days(90); let der = make_cert_der(not_before, yesterday); let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der); assert!( result.days_remaining <= 0, "expired cert should show 0 or negative days, got {}", result.days_remaining ); assert!(!result.valid, "expired cert should be invalid"); } // valid boundary at exactly +1 day pins `days_remaining > 0` #[test] fn tls_cert_one_day_remaining_is_valid() { // Pins `valid: days_remaining > 0`: 1 day must be valid (the only // way `>` and `>=` differ at the lower boundary is the 0 case which // is covered above; this confirms positive values flip to valid). let config = test_config(); let now = chrono::Utc::now(); let tomorrow = (now + chrono::Duration::days(1)) .date_naive() .and_hms_opt(12, 0, 0) .unwrap() .and_utc(); let not_before = now - chrono::Duration::days(30); let der = make_cert_der(not_before, tomorrow); let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der); assert!(result.valid); assert!(result.days_remaining >= 1); } // full-field population on a valid cert (subject/issuer) #[test] fn tls_cert_populates_subject_issuer_and_dates() { // Pins all the `cert.subject().to_string()`, `cert.issuer()...`, // not_before/not_after RFC3339 conversions. A mutation that // accidentally swapped subject and issuer, or returned empty strings, // would surface here. let config = test_config(); let now = chrono::Utc::now(); let not_before = now - chrono::Duration::days(30); let not_after = now + chrono::Duration::days(60); let der = make_cert_der(not_before, not_after); let result = parse_leaf_cert("test", &config, "2026-03-11T00:00:00Z", &der); assert!(result.valid); assert!(!result.subject.is_empty(), "subject must be populated"); assert!(!result.issuer.is_empty(), "issuer must be populated"); // rcgen self-signed certs use a default CN; we only assert non-empty. assert!( result.subject.contains("CN="), "subject should contain a CN: {}", result.subject ); // not_before/not_after should be parseable RFC3339 strings. assert!( chrono::DateTime::parse_from_rfc3339(&result.not_before).is_ok(), "not_before should be RFC3339: {}", result.not_before ); assert!( chrono::DateTime::parse_from_rfc3339(&result.not_after).is_ok(), "not_after should be RFC3339: {}", result.not_after ); assert!(result.error.is_none()); } #[test] fn tls_cert_far_future_has_many_days_remaining() { // Pins the arithmetic `(expiry_date - today).num_days()`: at +N days, // days_remaining must equal N within a 1-day tolerance. let config = test_config(); let now = chrono::Utc::now(); let plus_n = (now + chrono::Duration::days(100)) .date_naive() .and_hms_opt(0, 0, 0) .unwrap() .and_utc(); let der = make_cert_der(now - chrono::Duration::days(1), plus_n); let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der); assert!( (99..=100).contains(&result.days_remaining), "expected ~100 days, got {}", result.days_remaining ); } #[test] fn tls_error_helper_returns_invalid_status() { // Pins each field initialised in `tls_error`: a mutation that // returned `valid: true` or non-zero `days_remaining` would surface. let config = test_config(); let result = tls_error("svc", &config, "2026-03-11T00:00:00Z", "handshake failed"); assert!(!result.valid); assert_eq!(result.days_remaining, 0); assert!(result.subject.is_empty()); assert!(result.issuer.is_empty()); assert!(result.not_before.is_empty()); assert!(result.not_after.is_empty()); assert_eq!(result.error.as_deref(), Some("handshake failed")); } }