Skip to main content

max / makenotwork

18.6 KB · 504 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::TlsConnector;
7 use tokio_rustls::rustls;
8
9 use tracing::instrument;
10
11 use crate::config::TlsConfig;
12 use crate::types::TlsStatus;
13
14 /// Trust stores we probe against. Both are checked on every run: they answer
15 /// different questions and can legitimately disagree, so reporting only one
16 /// would hide a real failure mode.
17 ///
18 /// - [`TrustStore::Webpki`] is the bundled Mozilla root set. It answers "does
19 /// this chain validate against the public web PKI?", independent of whatever
20 /// the monitoring host happens to trust.
21 /// - [`TrustStore::Platform`] is the OS trust store. It answers "does this chain
22 /// validate for a client on this machine?", which additionally reflects
23 /// enterprise roots, admin-installed CAs, and OS-level distrust decisions.
24 ///
25 /// A chain trusted by one and rejected by the other is exactly the kind of drift
26 /// worth alerting on, so neither result is derived from the other.
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 enum TrustStore {
29 Webpki,
30 Platform,
31 }
32
33 /// The crypto provider backing both probes. `main` installs ring as the process
34 /// default; fall back to constructing it directly so the checks still work if
35 /// that install is ever removed or reordered.
36 fn crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
37 rustls::crypto::CryptoProvider::get_default()
38 .cloned()
39 .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider()))
40 }
41
42 /// Build a client config pinned to one trust store.
43 fn client_config(store: TrustStore) -> Result<rustls::ClientConfig, String> {
44 match store {
45 // Shared with every outbound HTTP probe (see crate::tls) so the HTTP
46 // reachability verdict and the cert-expiry verdict use one root set.
47 TrustStore::Webpki => Ok(crate::tls::webpki_client_config()),
48 TrustStore::Platform => {
49 let verifier = rustls_platform_verifier::Verifier::new(crypto_provider())
50 .map_err(|e| format!("platform verifier unavailable: {e}"))?;
51 Ok(rustls::ClientConfig::builder()
52 .dangerous()
53 .with_custom_certificate_verifier(Arc::new(verifier))
54 .with_no_client_auth())
55 }
56 }
57 }
58
59 /// Connect and handshake against one trust store, returning the peer chain.
60 ///
61 /// Each probe opens its own connection: a rustls handshake consumes the stream,
62 /// and the verifier is fixed when the config is built, so the two stores cannot
63 /// share one.
64 async fn probe(
65 addr: &str,
66 server_name: rustls_pki_types::ServerName<'static>,
67 store: TrustStore,
68 ) -> Result<Vec<rustls_pki_types::CertificateDer<'static>>, String> {
69 let tls_config = client_config(store)?;
70
71 let tcp =
72 match tokio::time::timeout(std::time::Duration::from_secs(10), TcpStream::connect(addr))
73 .await
74 {
75 Ok(Ok(stream)) => stream,
76 Ok(Err(e)) => return Err(format!("TCP connect failed: {e}")),
77 Err(_) => return Err("TCP connect timed out".to_string()),
78 };
79
80 let connector = TlsConnector::from(Arc::new(tls_config));
81 let tls_stream = match tokio::time::timeout(
82 std::time::Duration::from_secs(10),
83 connector.connect(server_name, tcp),
84 )
85 .await
86 {
87 Ok(Ok(stream)) => stream,
88 Ok(Err(e)) => return Err(format!("TLS handshake failed: {e}")),
89 Err(_) => return Err("TLS handshake timed out".to_string()),
90 };
91
92 let (_io, client_conn) = tls_stream.into_inner();
93 match client_conn.peer_certificates() {
94 Some(certs) if !certs.is_empty() => {
95 Ok(certs.iter().map(|c| c.clone().into_owned()).collect())
96 }
97 _ => Err("no peer certificates".to_string()),
98 }
99 }
100
101 /// Connect to host:port and inspect the leaf cert, verifying the chain against
102 /// both the bundled web-PKI roots and the OS trust store. See [`TrustStore`].
103 #[instrument(skip_all)]
104 pub async fn check_tls(target_name: &str, config: &TlsConfig) -> TlsStatus {
105 let checked_at = chrono::Utc::now().to_rfc3339();
106 let addr = format!("{}:{}", config.host, config.port);
107
108 let server_name = match rustls_pki_types::ServerName::try_from(config.host.clone()) {
109 Ok(name) => name,
110 Err(e) => {
111 return tls_error(
112 target_name,
113 config,
114 &checked_at,
115 &format!("invalid server name: {e}"),
116 );
117 }
118 };
119
120 let (webpki_result, platform_result) = tokio::join!(
121 probe(&addr, server_name.clone(), TrustStore::Webpki),
122 probe(&addr, server_name, TrustStore::Platform),
123 );
124
125 // Cert details come from whichever probe completed a handshake. Prefer the
126 // web-PKI chain so the reported fields stay stable across hosts, but fall
127 // back to the platform chain, a cert trusted only by a private root still
128 // has an expiry worth tracking.
129 let certs = webpki_result
130 .as_ref()
131 .ok()
132 .or(platform_result.as_ref().ok());
133
134 let Some(certs) = certs else {
135 // Both failed. Surface both reasons rather than just the first.
136 let webpki_err = webpki_result
137 .as_ref()
138 .err()
139 .map_or("unknown", String::as_str);
140 let platform_err = platform_result
141 .as_ref()
142 .err()
143 .map_or("unknown", String::as_str);
144 let mut status = tls_error(
145 target_name,
146 config,
147 &checked_at,
148 &format!("web PKI: {webpki_err}; platform: {platform_err}"),
149 );
150 status.webpki_error = webpki_result.err();
151 status.platform_error = platform_result.err();
152 return status;
153 };
154
155 let mut status = parse_leaf_cert(target_name, config, &checked_at, certs[0].as_ref());
156 status.webpki_trusted = webpki_result.is_ok();
157 status.platform_trusted = platform_result.is_ok();
158 status.webpki_error = webpki_result.err();
159 status.platform_error = platform_result.err();
160 // `valid` keeps its established meaning for the health contract: trusted by
161 // the public web PKI and not expired.
162 status.valid = status.valid && status.webpki_trusted;
163 status
164 }
165
166 /// Parse DER-encoded leaf cert bytes into a TlsStatus.
167 pub fn parse_leaf_cert(
168 target_name: &str,
169 config: &TlsConfig,
170 checked_at: &str,
171 der_bytes: &[u8],
172 ) -> TlsStatus {
173 use x509_parser::prelude::FromDer;
174 let (_, cert) = match x509_parser::prelude::X509Certificate::from_der(der_bytes) {
175 Ok(result) => result,
176 Err(e) => {
177 return tls_error(
178 target_name,
179 config,
180 checked_at,
181 &format!("cert parse error: {e}"),
182 );
183 }
184 };
185
186 let not_before_ts = cert.validity().not_before.timestamp();
187 let not_after_ts = cert.validity().not_after.timestamp();
188
189 let now = chrono::Utc::now();
190 let not_after_chrono = chrono::DateTime::from_timestamp(not_after_ts, 0).unwrap_or(now);
191 let not_before_chrono = chrono::DateTime::from_timestamp(not_before_ts, 0).unwrap_or(now);
192
193 // Use date-level comparison for consistent day boundary behavior.
194 // A certificate expiring today (same calendar day in UTC) gets 0 days remaining
195 // and is treated as expired. This avoids time-of-day inconsistencies where
196 // num_days() might return 0 for both "expires later today" and "expired earlier today".
197 let today = now.date_naive();
198 let expiry_date = not_after_chrono.date_naive();
199 let days_remaining = (expiry_date - today).num_days();
200
201 let subject = cert.subject().to_string();
202 let issuer = cert.issuer().to_string();
203
204 TlsStatus {
205 target: target_name.to_string(),
206 host: config.host.clone(),
207 port: config.port,
208 valid: days_remaining > 0,
209 days_remaining,
210 not_before: not_before_chrono.to_rfc3339(),
211 not_after: not_after_chrono.to_rfc3339(),
212 subject,
213 issuer,
214 checked_at: checked_at.to_string(),
215 error: None,
216 // Trust results are filled in by `check_tls`, which owns both probes.
217 // Callers parsing a bare cert (tests, cached DER) get the conservative
218 // "not established" default rather than a fabricated pass.
219 webpki_trusted: false,
220 platform_trusted: false,
221 webpki_error: None,
222 platform_error: None,
223 }
224 }
225
226 fn tls_error(target_name: &str, config: &TlsConfig, checked_at: &str, error: &str) -> TlsStatus {
227 TlsStatus {
228 target: target_name.to_string(),
229 host: config.host.clone(),
230 port: config.port,
231 valid: false,
232 days_remaining: 0,
233 not_before: String::new(),
234 not_after: String::new(),
235 subject: String::new(),
236 issuer: String::new(),
237 checked_at: checked_at.to_string(),
238 error: Some(error.to_string()),
239 webpki_trusted: false,
240 platform_trusted: false,
241 webpki_error: None,
242 platform_error: None,
243 }
244 }
245
246 #[cfg(test)]
247 mod tests {
248 use super::*;
249
250 /// Live-network check that both trust stores are actually exercised and
251 /// report independently. Ignored by default so the suite stays offline; run
252 /// with `cargo test, --ignored dual_trust` when touching the probe logic.
253 #[tokio::test]
254 #[ignore = "requires network access"]
255 async fn dual_trust_probes_agree_on_public_and_reject_bad_certs() {
256 let cfg = |host: &str| TlsConfig {
257 host: host.to_string(),
258 port: 443,
259 warn_days: 30,
260 };
261
262 // A normal public cert: trusted by the bundled web PKI and by the OS.
263 let good = check_tls("good", &cfg("makenot.work")).await;
264 assert!(
265 good.webpki_trusted,
266 "web PKI should trust makenot.work: {:?}",
267 good.webpki_error
268 );
269 assert!(
270 good.platform_trusted,
271 "OS store should trust makenot.work: {:?}",
272 good.platform_error
273 );
274 assert!(good.valid);
275 assert!(good.days_remaining > 0);
276
277 // An expired cert must be rejected by both, and each must say why
278 // rather than inheriting the other's verdict.
279 let expired = check_tls("expired", &cfg("expired.badssl.com")).await;
280 assert!(!expired.webpki_trusted);
281 assert!(!expired.platform_trusted);
282 assert!(expired.webpki_error.is_some());
283 assert!(expired.platform_error.is_some());
284 assert!(!expired.valid);
285
286 // A self-signed cert chains to no public or OS root.
287 let self_signed = check_tls("self-signed", &cfg("self-signed.badssl.com")).await;
288 assert!(!self_signed.webpki_trusted);
289 assert!(!self_signed.platform_trusted);
290 assert!(!self_signed.valid);
291 }
292
293 fn test_config() -> TlsConfig {
294 TlsConfig {
295 host: "example.com".to_string(),
296 port: 443,
297 warn_days: 14,
298 }
299 }
300
301 #[test]
302 fn parse_leaf_cert_with_invalid_der() {
303 let config = test_config();
304 let result = parse_leaf_cert("test", &config, "2026-03-11T00:00:00Z", b"not-a-cert");
305 assert!(!result.valid);
306 assert!(result.error.as_ref().unwrap().contains("cert parse error"));
307 }
308
309 #[test]
310 fn tls_error_populates_all_fields() {
311 let config = test_config();
312 let result = tls_error(
313 "test",
314 &config,
315 "2026-03-11T00:00:00Z",
316 "connection refused",
317 );
318 assert_eq!(result.target, "test");
319 assert_eq!(result.host, "example.com");
320 assert_eq!(result.port, 443);
321 assert!(!result.valid);
322 assert_eq!(result.days_remaining, 0);
323 assert_eq!(result.error.as_deref(), Some("connection refused"));
324 }
325
326 /// Generate a self-signed DER certificate with the given validity period.
327 fn make_cert_der(
328 not_before: chrono::DateTime<chrono::Utc>,
329 not_after: chrono::DateTime<chrono::Utc>,
330 ) -> Vec<u8> {
331 use rcgen::{CertificateParams, KeyPair};
332
333 let mut params = CertificateParams::new(vec!["example.com".to_string()]).unwrap();
334 // Convert chrono::DateTime<Utc> -> std::time::SystemTime -> time::OffsetDateTime
335 let nb_sys =
336 std::time::UNIX_EPOCH + std::time::Duration::from_secs(not_before.timestamp() as u64);
337 let na_sys =
338 std::time::UNIX_EPOCH + std::time::Duration::from_secs(not_after.timestamp() as u64);
339 params.not_before = nb_sys.into();
340 params.not_after = na_sys.into();
341
342 let key = KeyPair::generate().unwrap();
343 let cert = params.self_signed(&key).unwrap();
344 cert.der().to_vec()
345 }
346
347 #[test]
348 fn tls_cert_expiring_today_is_zero_days_and_invalid() {
349 let config = test_config();
350 let now = chrono::Utc::now();
351 let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
352 let today_end = now.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc();
353
354 // Cert that started yesterday, expires today
355 let not_before = today_start - chrono::Duration::days(1);
356 let der = make_cert_der(not_before, today_end);
357 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
358
359 assert_eq!(
360 result.days_remaining, 0,
361 "cert expiring today should show 0 days"
362 );
363 assert!(!result.valid, "cert expiring today should be invalid");
364 assert!(result.error.is_none());
365 }
366
367 #[test]
368 fn tls_cert_expiring_tomorrow_has_one_day_remaining() {
369 let config = test_config();
370 let now = chrono::Utc::now();
371 let tomorrow = (now + chrono::Duration::days(1))
372 .date_naive()
373 .and_hms_opt(23, 59, 59)
374 .unwrap()
375 .and_utc();
376
377 let not_before = now - chrono::Duration::days(30);
378 let der = make_cert_der(not_before, tomorrow);
379 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
380
381 assert_eq!(
382 result.days_remaining, 1,
383 "cert expiring tomorrow should show 1 day"
384 );
385 assert!(result.valid, "cert expiring tomorrow should be valid");
386 }
387
388 #[test]
389 fn tls_cert_already_expired_is_invalid() {
390 let config = test_config();
391 let now = chrono::Utc::now();
392 let yesterday = (now - chrono::Duration::days(1))
393 .date_naive()
394 .and_hms_opt(23, 59, 59)
395 .unwrap()
396 .and_utc();
397
398 let not_before = now - chrono::Duration::days(90);
399 let der = make_cert_der(not_before, yesterday);
400 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
401
402 assert!(
403 result.days_remaining <= 0,
404 "expired cert should show 0 or negative days, got {}",
405 result.days_remaining
406 );
407 assert!(!result.valid, "expired cert should be invalid");
408 }
409
410 // valid boundary at exactly +1 day pins `days_remaining > 0`
411
412 #[test]
413 fn tls_cert_one_day_remaining_is_valid() {
414 // Pins `valid: days_remaining > 0`: 1 day must be valid (the only
415 // way `>` and `>=` differ at the lower boundary is the 0 case which
416 // is covered above; this confirms positive values flip to valid).
417 let config = test_config();
418 let now = chrono::Utc::now();
419 let tomorrow = (now + chrono::Duration::days(1))
420 .date_naive()
421 .and_hms_opt(12, 0, 0)
422 .unwrap()
423 .and_utc();
424 let not_before = now - chrono::Duration::days(30);
425 let der = make_cert_der(not_before, tomorrow);
426 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
427 assert!(result.valid);
428 assert!(result.days_remaining >= 1);
429 }
430
431 // full-field population on a valid cert (subject/issuer)
432
433 #[test]
434 fn tls_cert_populates_subject_issuer_and_dates() {
435 // Pins all the `cert.subject().to_string()`, `cert.issuer()...`,
436 // not_before/not_after RFC3339 conversions. A mutation that
437 // accidentally swapped subject and issuer, or returned empty strings,
438 // would surface here.
439 let config = test_config();
440 let now = chrono::Utc::now();
441 let not_before = now - chrono::Duration::days(30);
442 let not_after = now + chrono::Duration::days(60);
443 let der = make_cert_der(not_before, not_after);
444 let result = parse_leaf_cert("test", &config, "2026-03-11T00:00:00Z", &der);
445
446 assert!(result.valid);
447 assert!(!result.subject.is_empty(), "subject must be populated");
448 assert!(!result.issuer.is_empty(), "issuer must be populated");
449 // rcgen self-signed certs use a default CN; we only assert non-empty.
450 assert!(
451 result.subject.contains("CN="),
452 "subject should contain a CN: {}",
453 result.subject
454 );
455 // not_before/not_after should be parseable RFC3339 strings.
456 assert!(
457 chrono::DateTime::parse_from_rfc3339(&result.not_before).is_ok(),
458 "not_before should be RFC3339: {}",
459 result.not_before
460 );
461 assert!(
462 chrono::DateTime::parse_from_rfc3339(&result.not_after).is_ok(),
463 "not_after should be RFC3339: {}",
464 result.not_after
465 );
466 assert!(result.error.is_none());
467 }
468
469 #[test]
470 fn tls_cert_far_future_has_many_days_remaining() {
471 // Pins the arithmetic `(expiry_date - today).num_days()`: at +N days,
472 // days_remaining must equal N within a 1-day tolerance.
473 let config = test_config();
474 let now = chrono::Utc::now();
475 let plus_n = (now + chrono::Duration::days(100))
476 .date_naive()
477 .and_hms_opt(0, 0, 0)
478 .unwrap()
479 .and_utc();
480 let der = make_cert_der(now - chrono::Duration::days(1), plus_n);
481 let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
482 assert!(
483 (99..=100).contains(&result.days_remaining),
484 "expected ~100 days, got {}",
485 result.days_remaining
486 );
487 }
488
489 #[test]
490 fn tls_error_helper_returns_invalid_status() {
491 // Pins each field initialised in `tls_error`: a mutation that
492 // returned `valid: true` or non-zero `days_remaining` would surface.
493 let config = test_config();
494 let result = tls_error("svc", &config, "2026-03-11T00:00:00Z", "handshake failed");
495 assert!(!result.valid);
496 assert_eq!(result.days_remaining, 0);
497 assert!(result.subject.is_empty());
498 assert!(result.issuer.is_empty());
499 assert!(result.not_before.is_empty());
500 assert!(result.not_after.is_empty());
501 assert_eq!(result.error.as_deref(), Some("handshake failed"));
502 }
503 }
504