Skip to main content

max / makenotwork

12.1 KB · 304 lines History Blame Raw
1 //! TLS certificate probing — connect to a host, inspect the leaf cert, track expiry.
2
3 use std::sync::Arc;
4
5 use tokio::net::TcpStream;
6 use tokio_rustls::rustls;
7 use tokio_rustls::TlsConnector;
8
9 use tracing::instrument;
10
11 use crate::config::TlsConfig;
12 use crate::types::TlsStatus;
13
14 /// Connect to host:port, complete TLS handshake, and extract leaf cert fields.
15 #[instrument(skip_all)]
16 pub async fn check_tls(target_name: &str, config: &TlsConfig) -> TlsStatus {
17 let checked_at = chrono::Utc::now().to_rfc3339();
18 let addr = format!("{}:{}", config.host, config.port);
19
20 // TCP connect with timeout
21 let tcp = match tokio::time::timeout(
22 std::time::Duration::from_secs(10),
23 TcpStream::connect(&addr),
24 )
25 .await
26 {
27 Ok(Ok(stream)) => stream,
28 Ok(Err(e)) => return tls_error(target_name, config, &checked_at, &format!("TCP connect failed: {e}")),
29 Err(_) => return tls_error(target_name, config, &checked_at, "TCP connect timed out"),
30 };
31
32 // Build rustls config with webpki trust store
33 let mut root_store = rustls::RootCertStore::empty();
34 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
35 let tls_config = rustls::ClientConfig::builder()
36 .with_root_certificates(root_store)
37 .with_no_client_auth();
38
39 let connector = TlsConnector::from(Arc::new(tls_config));
40 let server_name = match rustls_pki_types::ServerName::try_from(config.host.clone()) {
41 Ok(name) => name,
42 Err(e) => return tls_error(target_name, config, &checked_at, &format!("invalid server name: {e}")),
43 };
44
45 // TLS handshake with timeout
46 let tls_stream = match tokio::time::timeout(
47 std::time::Duration::from_secs(10),
48 connector.connect(server_name, tcp),
49 )
50 .await
51 {
52 Ok(Ok(stream)) => stream,
53 Ok(Err(e)) => return tls_error(target_name, config, &checked_at, &format!("TLS handshake failed: {e}")),
54 Err(_) => return tls_error(target_name, config, &checked_at, "TLS handshake timed out"),
55 };
56
57 // Extract peer certificates
58 let (_io, client_conn) = tls_stream.into_inner();
59 let certs = match client_conn.peer_certificates() {
60 Some(certs) if !certs.is_empty() => certs,
61 _ => return tls_error(target_name, config, &checked_at, "no peer certificates"),
62 };
63
64 // Parse the leaf (first) certificate
65 parse_leaf_cert(target_name, config, &checked_at, certs[0].as_ref())
66 }
67
68 /// Parse DER-encoded leaf cert bytes into a TlsStatus.
69 pub fn parse_leaf_cert(
70 target_name: &str,
71 config: &TlsConfig,
72 checked_at: &str,
73 der_bytes: &[u8],
74 ) -> TlsStatus {
75 use x509_parser::prelude::FromDer;
76 let (_, cert) = match x509_parser::prelude::X509Certificate::from_der(der_bytes) {
77 Ok(result) => result,
78 Err(e) => return tls_error(target_name, config, checked_at, &format!("cert parse error: {e}")),
79 };
80
81 let not_before_ts = cert.validity().not_before.timestamp();
82 let not_after_ts = cert.validity().not_after.timestamp();
83
84 let now = chrono::Utc::now();
85 let not_after_chrono = chrono::DateTime::from_timestamp(not_after_ts, 0)
86 .unwrap_or(now);
87 let not_before_chrono = chrono::DateTime::from_timestamp(not_before_ts, 0)
88 .unwrap_or(now);
89
90 // Use date-level comparison for consistent day boundary behavior.
91 // A certificate expiring today (same calendar day in UTC) gets 0 days remaining
92 // and is treated as expired. This avoids time-of-day inconsistencies where
93 // num_days() might return 0 for both "expires later today" and "expired earlier today".
94 let today = now.date_naive();
95 let expiry_date = not_after_chrono.date_naive();
96 let days_remaining = (expiry_date - today).num_days();
97
98 let subject = cert.subject().to_string();
99 let issuer = cert.issuer().to_string();
100
101 TlsStatus {
102 target: target_name.to_string(),
103 host: config.host.clone(),
104 port: config.port,
105 valid: days_remaining > 0,
106 days_remaining,
107 not_before: not_before_chrono.to_rfc3339(),
108 not_after: not_after_chrono.to_rfc3339(),
109 subject,
110 issuer,
111 checked_at: checked_at.to_string(),
112 error: None,
113 }
114 }
115
116 fn tls_error(target_name: &str, config: &TlsConfig, checked_at: &str, error: &str) -> TlsStatus {
117 TlsStatus {
118 target: target_name.to_string(),
119 host: config.host.clone(),
120 port: config.port,
121 valid: false,
122 days_remaining: 0,
123 not_before: String::new(),
124 not_after: String::new(),
125 subject: String::new(),
126 issuer: String::new(),
127 checked_at: checked_at.to_string(),
128 error: Some(error.to_string()),
129 }
130 }
131
132 #[cfg(test)]
133 mod tests {
134 use super::*;
135
136 fn test_config() -> TlsConfig {
137 TlsConfig {
138 host: "example.com".to_string(),
139 port: 443,
140 warn_days: 14,
141 }
142 }
143
144 #[test]
145 fn parse_leaf_cert_with_invalid_der() {
146 let config = test_config();
147 let result = parse_leaf_cert("test", &config, "2026-03-11T00:00:00Z", b"not-a-cert");
148 assert!(!result.valid);
149 assert!(result.error.as_ref().unwrap().contains("cert parse error"));
150 }
151
152 #[test]
153 fn tls_error_populates_all_fields() {
154 let config = test_config();
155 let result = tls_error("test", &config, "2026-03-11T00:00:00Z", "connection refused");
156 assert_eq!(result.target, "test");
157 assert_eq!(result.host, "example.com");
158 assert_eq!(result.port, 443);
159 assert!(!result.valid);
160 assert_eq!(result.days_remaining, 0);
161 assert_eq!(result.error.as_deref(), Some("connection refused"));
162 }
163
164 /// Generate a self-signed DER certificate with the given validity period.
165 fn make_cert_der(not_before: chrono::DateTime<chrono::Utc>, not_after: chrono::DateTime<chrono::Utc>) -> Vec<u8> {
166 use rcgen::{CertificateParams, KeyPair};
167
168 let mut params = CertificateParams::new(vec!["example.com".to_string()]).unwrap();
169 // Convert chrono::DateTime<Utc> -> std::time::SystemTime -> time::OffsetDateTime
170 let nb_sys = std::time::UNIX_EPOCH + std::time::Duration::from_secs(not_before.timestamp() as u64);
171 let na_sys = std::time::UNIX_EPOCH + std::time::Duration::from_secs(not_after.timestamp() as u64);
172 params.not_before = nb_sys.into();
173 params.not_after = na_sys.into();
174
175 let key = KeyPair::generate().unwrap();
176 let cert = params.self_signed(&key).unwrap();
177 cert.der().to_vec()
178 }
179
180 #[test]
181 fn tls_cert_expiring_today_is_zero_days_and_invalid() {
182 let config = test_config();
183 let now = chrono::Utc::now();
184 let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap()
185 .and_utc();
186 let today_end = now.date_naive().and_hms_opt(23, 59, 59).unwrap()
187 .and_utc();
188
189 // Cert that started yesterday, expires today
190 let not_before = today_start - chrono::Duration::days(1);
191 let der = make_cert_der(not_before, today_end);
192 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
193
194 assert_eq!(result.days_remaining, 0, "cert expiring today should show 0 days");
195 assert!(!result.valid, "cert expiring today should be invalid");
196 assert!(result.error.is_none());
197 }
198
199 #[test]
200 fn tls_cert_expiring_tomorrow_has_one_day_remaining() {
201 let config = test_config();
202 let now = chrono::Utc::now();
203 let tomorrow = (now + chrono::Duration::days(1)).date_naive()
204 .and_hms_opt(23, 59, 59).unwrap().and_utc();
205
206 let not_before = now - chrono::Duration::days(30);
207 let der = make_cert_der(not_before, tomorrow);
208 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
209
210 assert_eq!(result.days_remaining, 1, "cert expiring tomorrow should show 1 day");
211 assert!(result.valid, "cert expiring tomorrow should be valid");
212 }
213
214 #[test]
215 fn tls_cert_already_expired_is_invalid() {
216 let config = test_config();
217 let now = chrono::Utc::now();
218 let yesterday = (now - chrono::Duration::days(1)).date_naive()
219 .and_hms_opt(23, 59, 59).unwrap().and_utc();
220
221 let not_before = now - chrono::Duration::days(90);
222 let der = make_cert_der(not_before, yesterday);
223 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
224
225 assert!(result.days_remaining <= 0, "expired cert should show 0 or negative days, got {}", result.days_remaining);
226 assert!(!result.valid, "expired cert should be invalid");
227 }
228
229 // ── valid boundary at exactly +1 day pins `days_remaining > 0` ──
230
231 #[test]
232 fn tls_cert_one_day_remaining_is_valid() {
233 // Pins `valid: days_remaining > 0` — 1 day must be valid (the only
234 // way `>` and `>=` differ at the lower boundary is the 0 case which
235 // is covered above; this confirms positive values flip to valid).
236 let config = test_config();
237 let now = chrono::Utc::now();
238 let tomorrow = (now + chrono::Duration::days(1)).date_naive()
239 .and_hms_opt(12, 0, 0).unwrap().and_utc();
240 let not_before = now - chrono::Duration::days(30);
241 let der = make_cert_der(not_before, tomorrow);
242 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
243 assert!(result.valid);
244 assert!(result.days_remaining >= 1);
245 }
246
247 // ── full-field population on a valid cert (subject/issuer) ──
248
249 #[test]
250 fn tls_cert_populates_subject_issuer_and_dates() {
251 // Pins all the `cert.subject().to_string()`, `cert.issuer()...`,
252 // not_before/not_after RFC3339 conversions. A mutation that
253 // accidentally swapped subject and issuer, or returned empty strings,
254 // would surface here.
255 let config = test_config();
256 let now = chrono::Utc::now();
257 let not_before = now - chrono::Duration::days(30);
258 let not_after = now + chrono::Duration::days(60);
259 let der = make_cert_der(not_before, not_after);
260 let result = parse_leaf_cert("test", &config, "2026-03-11T00:00:00Z", &der);
261
262 assert!(result.valid);
263 assert!(!result.subject.is_empty(), "subject must be populated");
264 assert!(!result.issuer.is_empty(), "issuer must be populated");
265 // rcgen self-signed certs use a default CN; we only assert non-empty.
266 assert!(result.subject.contains("CN="), "subject should contain a CN: {}", result.subject);
267 // not_before/not_after should be parseable RFC3339 strings.
268 assert!(chrono::DateTime::parse_from_rfc3339(&result.not_before).is_ok(),
269 "not_before should be RFC3339: {}", result.not_before);
270 assert!(chrono::DateTime::parse_from_rfc3339(&result.not_after).is_ok(),
271 "not_after should be RFC3339: {}", result.not_after);
272 assert!(result.error.is_none());
273 }
274
275 #[test]
276 fn tls_cert_far_future_has_many_days_remaining() {
277 // Pins the arithmetic `(expiry_date - today).num_days()` — at +N days,
278 // days_remaining must equal N within a 1-day tolerance.
279 let config = test_config();
280 let now = chrono::Utc::now();
281 let plus_n = (now + chrono::Duration::days(100)).date_naive()
282 .and_hms_opt(0, 0, 0).unwrap().and_utc();
283 let der = make_cert_der(now - chrono::Duration::days(1), plus_n);
284 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
285 assert!((99..=100).contains(&result.days_remaining),
286 "expected ~100 days, got {}", result.days_remaining);
287 }
288
289 #[test]
290 fn tls_error_helper_returns_invalid_status() {
291 // Pins each field initialised in `tls_error` — a mutation that
292 // returned `valid: true` or non-zero `days_remaining` would surface.
293 let config = test_config();
294 let result = tls_error("svc", &config, "2026-03-11T00:00:00Z", "handshake failed");
295 assert!(!result.valid);
296 assert_eq!(result.days_remaining, 0);
297 assert!(result.subject.is_empty());
298 assert!(result.issuer.is_empty());
299 assert!(result.not_before.is_empty());
300 assert!(result.not_after.is_empty());
301 assert_eq!(result.error.as_deref(), Some("handshake failed"));
302 }
303 }
304