1use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use serde::{Deserialize, Serialize};
11use tracing::{debug, info, warn};
12
13use super::pool::{CaId, CaPool};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub enum HealthState {
25 Healthy,
27 Degraded,
29 Unavailable,
31 Recovering,
33}
34
35impl HealthState {
36 pub fn is_available(&self) -> bool {
38 matches!(self, Self::Healthy | Self::Degraded | Self::Recovering)
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct HealthConfig {
45 pub probe_interval: Duration,
47 pub probe_timeout: Duration,
49 pub recovery_threshold: u32,
52 pub degraded_latency_ms: u64,
54}
55
56impl Default for HealthConfig {
57 fn default() -> Self {
58 Self {
59 probe_interval: Duration::from_secs(30),
60 probe_timeout: Duration::from_secs(5),
61 recovery_threshold: 2,
62 degraded_latency_ms: 2000,
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct ProbeMetrics {
70 pub last_check: Option<Instant>,
72 pub consecutive_failures: u32,
74 pub consecutive_successes: u32,
76 pub last_latency: Option<Duration>,
78}
79
80impl ProbeMetrics {
81 fn new() -> Self {
82 Self {
83 last_check: None,
84 consecutive_failures: 0,
85 consecutive_successes: 0,
86 last_latency: None,
87 }
88 }
89}
90
91pub struct LocalProbeContext {
92 pub config: Arc<crate::config::Config>,
93 pub cas: Arc<indexmap::IndexMap<String, Arc<crate::state::CaState>>>,
94 pub hsm: Option<Arc<kipuka_hsm::HsmContext>>,
95}
96
97pub fn probe_local_ca(
112 ca_cfg: &crate::config::CaConfig,
113 ca_state: &crate::state::CaState,
114 hsm: Option<&Arc<kipuka_hsm::HsmContext>>,
115) -> Result<(), String> {
116 let key = crate::ca::issue::resolve_signing_key_sync(ca_cfg, hsm).map_err(|e| e.to_string())?;
117 let cert = openssl::x509::X509::from_der(&ca_state.cert_der).map_err(|e| e.to_string())?;
118 match key {
119 crate::ca::issue::ResolvedSigningKey::Pem(pem) => {
120 let key = openssl::pkey::PKey::private_key_from_pem(&pem).map_err(|e| e.to_string())?;
121 let public_key = cert.public_key().map_err(|e| e.to_string())?;
122 if !key.public_eq(&public_key) {
123 return Err("CA key does not match its certificate".into());
124 }
125 }
126 crate::ca::issue::ResolvedSigningKey::Hsm { context, .. } => {
127 context.health_check().map_err(|e| e.to_string())?
128 }
129 }
130 let now = openssl::asn1::Asn1Time::days_from_now(0).map_err(|e| e.to_string())?;
131 if cert.not_before() > now || cert.not_after() <= now {
132 return Err("CA certificate is outside its validity period".into());
133 }
134 Ok(())
135}
136
137#[derive(Clone)]
142pub struct HealthChecker {
143 pool: Arc<CaPool>,
144 config: HealthConfig,
145 local: Option<Arc<LocalProbeContext>>,
146 metrics: Arc<parking_lot::RwLock<std::collections::HashMap<CaId, ProbeMetrics>>>,
148}
149
150impl HealthChecker {
151 pub fn new(pool: Arc<CaPool>, config: HealthConfig) -> Self {
153 let mut metrics = std::collections::HashMap::new();
154 for conn in pool.connections() {
155 metrics.insert(conn.id.clone(), ProbeMetrics::new());
156 }
157
158 Self {
159 pool,
160 config,
161 local: None,
162 metrics: Arc::new(parking_lot::RwLock::new(metrics)),
163 }
164 }
165
166 pub fn with_local(mut self, local: Arc<LocalProbeContext>) -> Self {
167 self.local = Some(local);
168 self
169 }
170
171 pub fn interval(&self) -> Duration {
173 self.config.probe_interval
174 }
175
176 pub async fn run_probes(&self) {
178 debug!("starting health probe round");
179
180 for conn in self.pool.connections() {
181 let start = Instant::now();
182 let result = self.probe_ca(&conn.id).await;
183 let elapsed = start.elapsed();
184
185 let mut metrics = self.metrics.write();
186 let m = metrics
187 .entry(conn.id.clone())
188 .or_insert_with(ProbeMetrics::new);
189 m.last_check = Some(Instant::now());
190 m.last_latency = Some(elapsed);
191
192 match result {
193 Ok(()) => self.handle_probe_success(&conn.id, elapsed, m),
194 Err(e) => self.handle_probe_failure(&conn.id, e, m),
195 }
196 }
197 }
198
199 async fn probe_ca(&self, id: &CaId) -> Result<(), String> {
209 let current_snapshot = self.pool.status_snapshot();
212 let is_unavailable = current_snapshot
213 .get(id)
214 .map(|s| s.health == HealthState::Unavailable)
215 .unwrap_or(false);
216
217 if is_unavailable && !self.pool.should_reprobe(id) {
218 return Err("circuit breaker open, cooldown not elapsed".to_string());
219 }
220
221 if is_unavailable {
222 debug!(ca = %id, "cooldown elapsed, attempting re-probe");
223 }
224
225 let endpoint = self
227 .pool
228 .connections()
229 .iter()
230 .find(|c| c.id == *id)
231 .map(|c| c.endpoint.clone())
232 .ok_or_else(|| format!("CA {id} not found in pool"))?;
233
234 if endpoint.starts_with("local:") {
235 let context = self.local.as_ref().ok_or("local probe context missing")?;
236 let ca = context.cas.get(&id.0).ok_or("local CA state missing")?;
237 let config = context
238 .config
239 .cas
240 .iter()
241 .find(|ca| ca.id == id.0)
242 .ok_or("local CA configuration missing")?;
243 return probe_local_ca(config, ca, context.hsm.as_ref());
244 }
245
246 let health_url = if endpoint.contains("/ca") {
250 format!("{}/admin/ca/getStatus", endpoint.trim_end_matches('/'))
252 } else {
253 endpoint.clone()
255 };
256
257 debug!(ca = %id, url = %health_url, "probing CA health endpoint");
258
259 let client = reqwest::Client::builder()
260 .timeout(self.config.probe_timeout)
261 .redirect(reqwest::redirect::Policy::none())
262 .build()
263 .map_err(|e| format!("HTTP client build failed: {e}"))?;
264
265 let response = client.get(&health_url).send().await.map_err(|e| {
266 if e.is_timeout() {
267 format!(
268 "health probe timed out after {:?}",
269 self.config.probe_timeout
270 )
271 } else if e.is_connect() {
272 format!("health probe connection refused: {e}")
273 } else {
274 format!("health probe failed: {e}")
275 }
276 })?;
277
278 let status = response.status();
279 if !status.is_success() {
280 return Err(format!("CA returned server error: HTTP {status}"));
281 }
282
283 if health_url.contains("getStatus") {
287 let mut response = response;
288 let mut bytes = Vec::new();
289 while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
290 if bytes.len() + chunk.len() > 65536 {
291 return Err("health response exceeds 64 KiB".into());
292 }
293 bytes.extend_from_slice(&chunk);
294 }
295 let body = String::from_utf8_lossy(&bytes);
296 if !body.to_lowercase().contains("running") {
297 return Err(format!(
298 "Dogtag CA reports unhealthy status: {}",
299 body.chars().take(200).collect::<String>()
300 ));
301 }
302 }
303
304 debug!(ca = %id, status = %status, "CA health probe succeeded");
305 Ok(())
306 }
307
308 fn handle_probe_success(&self, id: &CaId, latency: Duration, metrics: &mut ProbeMetrics) {
310 metrics.consecutive_failures = 0;
311 metrics.consecutive_successes += 1;
312
313 let current_snapshot = self.pool.status_snapshot();
314 let current_health = current_snapshot
315 .get(id)
316 .map(|s| s.health.clone())
317 .unwrap_or(HealthState::Healthy);
318
319 let latency_ms = latency.as_millis() as u64;
320
321 let new_state = match current_health {
322 HealthState::Unavailable => {
323 info!(ca = %id, "CA responding again, entering recovery");
324 metrics.consecutive_successes = 1;
325 HealthState::Recovering
326 }
327 HealthState::Recovering => {
328 if metrics.consecutive_successes >= self.config.recovery_threshold {
329 info!(ca = %id, "CA recovery confirmed, marking healthy");
330 HealthState::Healthy
331 } else {
332 debug!(
333 ca = %id,
334 successes = metrics.consecutive_successes,
335 needed = self.config.recovery_threshold,
336 "CA still recovering"
337 );
338 HealthState::Recovering
339 }
340 }
341 HealthState::Degraded | HealthState::Healthy => {
342 if latency_ms > self.config.degraded_latency_ms {
343 debug!(ca = %id, latency_ms, "CA responding slowly, marking degraded");
344 HealthState::Degraded
345 } else {
346 HealthState::Healthy
347 }
348 }
349 };
350
351 self.pool.set_health(id, new_state);
352 self.pool.record_success(id, latency);
353 }
354
355 fn handle_probe_failure(&self, id: &CaId, error: String, metrics: &mut ProbeMetrics) {
357 metrics.consecutive_failures += 1;
358 metrics.consecutive_successes = 0;
359
360 warn!(
361 ca = %id,
362 failures = metrics.consecutive_failures,
363 error = %error,
364 "health probe failed"
365 );
366
367 self.pool.record_failure(id);
368 }
369
370 pub fn metrics_snapshot(&self) -> std::collections::HashMap<CaId, ProbeMetrics> {
372 self.metrics.read().clone()
373 }
374}
375
376#[cfg(test)]
377mod probe_local_ca_tests {
378 use super::*;
387 use openssl::asn1::Asn1Time;
388 use openssl::bn::{BigNum, MsbOption};
389 use openssl::ec::{EcGroup, EcKey};
390 use openssl::hash::MessageDigest;
391 use openssl::nid::Nid;
392 use openssl::pkey::{PKey, Private};
393 use openssl::x509::{X509, X509NameBuilder};
394 use std::io::Write;
395
396 fn gen_ec_key() -> PKey<Private> {
397 let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap();
398 let ec = EcKey::generate(&group).unwrap();
399 PKey::from_ec_key(ec).unwrap()
400 }
401
402 fn self_signed(key: &PKey<Private>, valid_days: i64) -> X509 {
405 let mut nb = X509NameBuilder::new().unwrap();
406 nb.append_entry_by_text("CN", "probe-test-ca").unwrap();
407 let name = nb.build();
408
409 let mut b = X509::builder().unwrap();
410 b.set_version(2).unwrap();
411
412 let mut serial = BigNum::new().unwrap();
413 serial.rand(128, MsbOption::MAYBE_ZERO, false).unwrap();
414 b.set_serial_number(&serial.to_asn1_integer().unwrap())
415 .unwrap();
416
417 b.set_subject_name(&name).unwrap();
418 b.set_issuer_name(&name).unwrap();
419 b.set_pubkey(key).unwrap();
420
421 let now = std::time::SystemTime::now()
422 .duration_since(std::time::UNIX_EPOCH)
423 .unwrap()
424 .as_secs() as i64;
425 b.set_not_before(&Asn1Time::from_unix(now - 3600).unwrap())
426 .unwrap();
427 b.set_not_after(&Asn1Time::from_unix(now + valid_days * 86_400).unwrap())
428 .unwrap();
429
430 b.sign(key, MessageDigest::sha256()).unwrap();
431 b.build()
432 }
433
434 fn ca_state(cert_der: Vec<u8>) -> crate::state::CaState {
435 crate::state::CaState {
436 id: "probe-test".into(),
437 key_type: "ec:P-256".into(),
438 cert_der,
439 cert_chain: vec![],
440 hash_algorithm: "sha256".into(),
441 validity_days: 90,
442 crl_url: None,
443 ocsp_url: None,
444 crl_cache: parking_lot::Mutex::new(None),
445 cab_forum_compliant: false,
446 }
447 }
448
449 fn ca_config(key_path: &std::path::Path) -> crate::config::CaConfig {
450 let toml = format!(
454 "id = \"probe-test\"\nkey_file = \"{}\"\ncert_file = \"/dev/null\"\n",
455 key_path.display()
456 );
457 toml::from_str(&toml).expect("valid CaConfig toml")
458 }
459
460 fn write_key(key: &PKey<Private>) -> tempfile::NamedTempFile {
461 let mut f = tempfile::NamedTempFile::new().unwrap();
462 f.write_all(&key.private_key_to_pem_pkcs8().unwrap())
463 .unwrap();
464 f.flush().unwrap();
465 f
466 }
467
468 #[test]
469 fn healthy_ca_passes_liveness_check() {
470 let key = gen_ec_key();
471 let cert = self_signed(&key, 365);
472 let key_file = write_key(&key);
473 let cfg = ca_config(key_file.path());
474 let state = ca_state(cert.to_der().unwrap());
475
476 assert!(
477 probe_local_ca(&cfg, &state, None).is_ok(),
478 "a CA whose on-disk key matches its in-date certificate must be reported live"
479 );
480 }
481
482 #[test]
483 fn mismatched_key_and_cert_fails_liveness_check() {
484 let key1 = gen_ec_key();
488 let key2 = gen_ec_key();
489 let cert = self_signed(&key2, 365);
490 let key_file = write_key(&key1);
491 let cfg = ca_config(key_file.path());
492 let state = ca_state(cert.to_der().unwrap());
493
494 let err = probe_local_ca(&cfg, &state, None)
495 .expect_err("a CA whose key does not match its certificate must fail");
496 assert!(err.contains("does not match"), "unexpected reason: {err}");
497 }
498
499 #[test]
500 fn expired_ca_cert_fails_liveness_check() {
501 let key = gen_ec_key();
502 let cert = self_signed(&key, -1); let key_file = write_key(&key);
504 let cfg = ca_config(key_file.path());
505 let state = ca_state(cert.to_der().unwrap());
506
507 let err = probe_local_ca(&cfg, &state, None)
508 .expect_err("an expired CA certificate must fail the liveness check");
509 assert!(err.contains("validity period"), "unexpected reason: {err}");
510 }
511}