Skip to main content

kipuka/routes/admin/
health.rs

1//! System health check endpoints for the admin API.
2//!
3//! Provides health probes for the overall system and individual
4//! subsystems (database, HSM, CA backends).  These endpoints are
5//! designed for monitoring systems (Kubernetes readiness probes,
6//! Prometheus health checks, etc.).
7
8use std::sync::Arc;
9
10use axum::Json;
11use axum::extract::State;
12use axum::http::StatusCode;
13use axum::response::{IntoResponse, Response};
14use serde::Serialize;
15
16use super::AdminAuth;
17use crate::state::AppState;
18
19/// Overall system health status.
20#[derive(Serialize)]
21pub struct SystemHealth {
22    /// Overall status: "healthy", "degraded", or "unhealthy".
23    pub status: String,
24
25    /// Server uptime in seconds.
26    pub uptime_secs: u64,
27
28    /// Database health.
29    pub database: SubsystemHealth,
30
31    /// HSM health (if configured).
32    pub hsm: Option<SubsystemHealth>,
33
34    /// Number of configured CAs.
35    pub ca_count: usize,
36
37    /// Number of healthy CAs.
38    pub healthy_ca_count: usize,
39
40    /// Server version.
41    pub version: String,
42}
43
44/// Health status of an individual subsystem.
45#[derive(Serialize)]
46pub struct SubsystemHealth {
47    /// Subsystem name.
48    pub name: String,
49
50    /// Status: "healthy", "degraded", or "unhealthy".
51    pub status: String,
52
53    /// Optional detail message.
54    pub detail: Option<String>,
55
56    /// Response latency in milliseconds (if measured).
57    pub latency_ms: Option<u64>,
58}
59
60/// `GET /admin/health` — Overall system health.
61///
62/// Returns the aggregate health of all subsystems.  The HTTP status
63/// code reflects the overall health:
64///
65/// - `200 OK` — all subsystems healthy
66/// - `503 Service Unavailable` — one or more critical subsystems unhealthy
67pub async fn get_health(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
68    let uptime = state.startup_time.elapsed().as_secs();
69
70    // Check database health.
71    let db_health = check_database_health(&state).await;
72
73    // Check HSM health (if configured).
74    let hsm_health = if state.config.hsm.is_some() {
75        Some(check_hsm_health(&state).await)
76    } else {
77        None
78    };
79
80    // Count healthy CAs.
81    let ca_count = state.config.cas.len();
82    let healthy_ca_count = match state.ha_manager.as_ref() {
83        // HA enabled: the background health checker maintains a live
84        // availability map for every backend; trust its snapshot.
85        Some(ha) => ha
86            .pool()
87            .status_snapshot()
88            .into_values()
89            .filter(|s| s.health.is_available())
90            .count(),
91        // HA disabled: there is no background probe, so actively verify each
92        // locally-hosted CA here. This branch previously assumed every CA was
93        // healthy (`.unwrap_or(ca_count)`), which meant the endpoint could
94        // report "healthy" for a CA whose signing key was missing or mismatched
95        // or whose certificate had expired — a false assurance.
96        None => state
97            .config
98            .cas
99            .iter()
100            .filter(|ca_cfg| match state.cas.get(&ca_cfg.id) {
101                Some(ca_state) => {
102                    match crate::ha::health::probe_local_ca(ca_cfg, ca_state, state.hsm.as_ref()) {
103                        Ok(()) => true,
104                        Err(reason) => {
105                            tracing::warn!(ca = %ca_cfg.id, %reason, "CA failed liveness check");
106                            false
107                        }
108                    }
109                }
110                None => {
111                    tracing::warn!(ca = %ca_cfg.id, "configured CA has no runtime state");
112                    false
113                }
114            })
115            .count(),
116    };
117
118    // Determine overall status.
119    let overall_status =
120        if db_health.status == "unhealthy" || (healthy_ca_count == 0 && ca_count > 0) {
121            "unhealthy"
122        } else if db_health.status == "degraded" || healthy_ca_count < ca_count {
123            "degraded"
124        } else {
125            "healthy"
126        };
127
128    let health = SystemHealth {
129        status: overall_status.to_string(),
130        uptime_secs: uptime,
131        database: db_health,
132        hsm: hsm_health,
133        ca_count,
134        healthy_ca_count,
135        version: env!("CARGO_PKG_VERSION").to_string(),
136    };
137
138    let status_code = if overall_status == "unhealthy" {
139        StatusCode::SERVICE_UNAVAILABLE
140    } else {
141        StatusCode::OK
142    };
143
144    state
145        .record_audit_event("admin_health_check", &format!("status={overall_status}"))
146        .await;
147
148    (status_code, Json(health)).into_response()
149}
150
151/// `GET /admin/health/db` — Database connectivity check.
152///
153/// Performs a lightweight query to verify database connectivity.
154pub async fn get_health_db(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
155    let health = check_database_health(&state).await;
156
157    let status = if health.status == "healthy" {
158        StatusCode::OK
159    } else {
160        StatusCode::SERVICE_UNAVAILABLE
161    };
162
163    state
164        .record_audit_event(
165            "admin_health_check",
166            &format!("subsystem=database, status={}", health.status),
167        )
168        .await;
169
170    (status, Json(health)).into_response()
171}
172
173/// `GET /admin/health/hsm` — HSM connectivity check.
174///
175/// Verifies that the configured HSM is reachable and the PKCS#11
176/// session is active.
177pub async fn get_health_hsm(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
178    if state.config.hsm.is_none() {
179        return (
180            StatusCode::OK,
181            Json(SubsystemHealth {
182                name: "hsm".to_string(),
183                status: "not_configured".to_string(),
184                detail: Some("no HSM is configured".to_string()),
185                latency_ms: None,
186            }),
187        )
188            .into_response();
189    }
190
191    let health = check_hsm_health(&state).await;
192    let status = if health.status == "healthy" {
193        StatusCode::OK
194    } else {
195        StatusCode::SERVICE_UNAVAILABLE
196    };
197
198    state
199        .record_audit_event(
200            "admin_health_check",
201            &format!("subsystem=hsm, status={}", health.status),
202        )
203        .await;
204
205    (status, Json(health)).into_response()
206}
207
208/// `GET /admin/health/ca` — CA backend health for all configured CAs.
209///
210/// Returns the health status of each configured CA backend from the
211/// HA subsystem.
212pub async fn get_health_ca(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
213    let mut ca_health: Vec<serde_json::Value> = Vec::new();
214
215    for ca_config in &state.config.cas {
216        let (health, latency_ms) = match state.ha_manager.as_ref() {
217            // HA enabled: report the backend's live state from the background
218            // health checker's snapshot (Healthy/Degraded/Unavailable/Recovering).
219            Some(ha) => {
220                let ca_id_key = crate::ha::CaId(ca_config.id.clone());
221                ha.pool()
222                    .status_snapshot()
223                    .get(&ca_id_key)
224                    .map(|s| (format!("{:?}", s.health), Some(s.latency_ema_ms as u64)))
225                    .unwrap_or(("unknown".to_string(), None))
226            }
227            // HA disabled: there is no background probe, so this endpoint used
228            // to report "unknown" for every CA. Actively run the same one-shot
229            // liveness check the aggregate `/admin/health` count uses, so the
230            // per-CA view is honest instead of uninformative. Reported in the
231            // HealthState vocabulary the HA-enabled branch already emits.
232            None => match state.cas.get(&ca_config.id) {
233                Some(ca_state) => {
234                    match crate::ha::health::probe_local_ca(ca_config, ca_state, state.hsm.as_ref())
235                    {
236                        Ok(()) => ("Healthy".to_string(), None),
237                        Err(reason) => {
238                            tracing::warn!(ca = %ca_config.id, %reason, "CA failed liveness check");
239                            ("Unavailable".to_string(), None)
240                        }
241                    }
242                }
243                None => {
244                    tracing::warn!(ca = %ca_config.id, "configured CA has no runtime state");
245                    ("unknown".to_string(), None)
246                }
247            },
248        };
249
250        ca_health.push(serde_json::json!({
251            "ca_id": ca_config.id,
252            "health": health,
253            "latency_ms": latency_ms,
254            "hsm_backed": ca_config.is_hsm_backed(),
255        }));
256    }
257
258    state
259        .record_audit_event(
260            "admin_health_check",
261            &format!("subsystem=ca, count={}", ca_health.len()),
262        )
263        .await;
264
265    (StatusCode::OK, Json(ca_health)).into_response()
266}
267
268// ── Internal health check implementations ────────────────────────────────────
269
270/// Check database connectivity with a lightweight query.
271///
272/// A 2-second timeout prevents a hung database connection from stalling
273/// the health endpoint (and upstream readiness probes).
274async fn check_database_health(state: &AppState) -> SubsystemHealth {
275    let start = std::time::Instant::now();
276
277    let result = tokio::time::timeout(
278        std::time::Duration::from_secs(2),
279        sqlx::query("SELECT 1").execute(&state.db),
280    )
281    .await;
282
283    match result {
284        Ok(Ok(_)) => {
285            let latency = start.elapsed().as_millis() as u64;
286            SubsystemHealth {
287                name: "database".to_string(),
288                status: "healthy".to_string(),
289                detail: None,
290                latency_ms: Some(latency),
291            }
292        }
293        Ok(Err(e)) => {
294            let latency = start.elapsed().as_millis() as u64;
295            tracing::error!(error = %e, "database health check failed");
296            SubsystemHealth {
297                name: "database".to_string(),
298                status: "unhealthy".to_string(),
299                detail: Some(format!("database unreachable: {e}")),
300                latency_ms: Some(latency),
301            }
302        }
303        Err(_elapsed) => {
304            let latency = start.elapsed().as_millis() as u64;
305            tracing::error!("database health check timed out after 2s");
306            SubsystemHealth {
307                name: "database".to_string(),
308                status: "unhealthy".to_string(),
309                detail: Some("database health check timed out (2s)".to_string()),
310                latency_ms: Some(latency),
311            }
312        }
313    }
314}
315
316/// Check HSM connectivity by verifying the PKCS#11 session is active.
317async fn check_hsm_health(state: &AppState) -> SubsystemHealth {
318    let start = std::time::Instant::now();
319
320    if let Some(ref hsm) = state.hsm {
321        match hsm.health_check() {
322            Ok(()) => {
323                let latency = start.elapsed().as_millis() as u64;
324                SubsystemHealth {
325                    name: "hsm".to_string(),
326                    status: "healthy".to_string(),
327                    detail: Some("HSM session active".to_string()),
328                    latency_ms: Some(latency),
329                }
330            }
331            Err(e) => {
332                let latency = start.elapsed().as_millis() as u64;
333                tracing::error!(error = %e, "HSM health check failed");
334                SubsystemHealth {
335                    name: "hsm".to_string(),
336                    status: "unhealthy".to_string(),
337                    detail: Some(format!("HSM unreachable: {e}")),
338                    latency_ms: Some(latency),
339                }
340            }
341        }
342    } else {
343        SubsystemHealth {
344            name: "hsm".to_string(),
345            status: "not_configured".to_string(),
346            detail: Some("no HSM context available".to_string()),
347            latency_ms: None,
348        }
349    }
350}