//! TLS certificate probing — connect to a host, inspect the leaf cert, track expiry. use std::sync::Arc; use tokio::net::TcpStream; use tokio_rustls::rustls; use tokio_rustls::TlsConnector; use tracing::instrument; use crate::config::TlsConfig; use crate::types::TlsStatus; /// Connect to host:port, complete TLS handshake, and extract leaf cert fields. #[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); // TCP connect with timeout let tcp = match tokio::time::timeout( std::time::Duration::from_secs(10), TcpStream::connect(&addr), ) .await { Ok(Ok(stream)) => stream, Ok(Err(e)) => return tls_error(target_name, config, &checked_at, &format!("TCP connect failed: {e}")), Err(_) => return tls_error(target_name, config, &checked_at, "TCP connect timed out"), }; // Build rustls config with webpki trust store let mut root_store = rustls::RootCertStore::empty(); root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); let tls_config = rustls::ClientConfig::builder() .with_root_certificates(root_store) .with_no_client_auth(); let connector = TlsConnector::from(Arc::new(tls_config)); 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}")), }; // TLS handshake with timeout 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 tls_error(target_name, config, &checked_at, &format!("TLS handshake failed: {e}")), Err(_) => return tls_error(target_name, config, &checked_at, "TLS handshake timed out"), }; // Extract peer certificates let (_io, client_conn) = tls_stream.into_inner(); let certs = match client_conn.peer_certificates() { Some(certs) if !certs.is_empty() => certs, _ => return tls_error(target_name, config, &checked_at, "no peer certificates"), }; // Parse the leaf (first) certificate parse_leaf_cert(target_name, config, &checked_at, certs[0].as_ref()) } /// 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, } } 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()), } } #[cfg(test)] mod tests { use super::*; 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")); } }