Skip to main content

kipuka/ca/
pool.rs

1//! CA backend pool for HA enrollment routing.
2//!
3//! Routes EST enrollment requests to healthy CA backends using the
4//! HA subsystem's failover strategy. Provides retry logic and
5//! connection management.
6
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use thiserror::Error;
11use tracing::{debug, info, warn};
12
13use crate::ha::pool::{CaId, CaPool};
14
15/// Errors during CA backend operations.
16#[derive(Debug, Error)]
17pub enum CaBackendError {
18    /// No healthy CA backend is available.
19    #[error("no healthy CA backend available")]
20    NoHealthyBackend,
21
22    /// The request timed out.
23    #[error("request to CA {ca_id} timed out after {elapsed_ms}ms")]
24    Timeout { ca_id: String, elapsed_ms: u64 },
25
26    /// All retry attempts exhausted.
27    #[error("all {attempts} retry attempts exhausted")]
28    RetriesExhausted { attempts: u32 },
29
30    /// Backend request failed.
31    #[error("CA backend error from {ca_id}: {message}")]
32    BackendError { ca_id: String, message: String },
33}
34
35/// Configuration for the CA backend pool.
36#[derive(Debug, Clone)]
37pub struct CaBackendPoolConfig {
38    /// Request timeout per CA backend attempt.
39    pub request_timeout: Duration,
40    /// Maximum number of retry attempts with different CAs.
41    pub max_retries: u32,
42    /// Whether to keep connections alive for reuse.
43    pub keep_alive: bool,
44}
45
46impl Default for CaBackendPoolConfig {
47    fn default() -> Self {
48        Self {
49            request_timeout: Duration::from_secs(30),
50            max_retries: 2,
51            keep_alive: true,
52        }
53    }
54}
55
56/// Routes enrollment requests to healthy CA backends via the HA pool.
57///
58/// Wraps the HA [`CaPool`] with retry logic and timeout management
59/// for enrollment operations (simpleenroll, simplereenroll, serverkeygen).
60pub struct CaBackendPool {
61    /// The underlying HA pool for CA selection.
62    ha_pool: Arc<CaPool>,
63    /// Pool configuration.
64    config: CaBackendPoolConfig,
65}
66
67impl CaBackendPool {
68    /// Create a new backend pool wrapping the HA pool.
69    pub fn new(ha_pool: Arc<CaPool>, config: CaBackendPoolConfig) -> Self {
70        Self { ha_pool, config }
71    }
72
73    /// Route a certificate issuance request to a healthy CA.
74    ///
75    /// Selects a CA via the HA strategy, sends the request, and retries
76    /// with the next available CA on failure (up to `max_retries`).
77    ///
78    /// # Arguments
79    ///
80    /// * `csr_der` - DER-encoded CSR to submit
81    /// * `profile` - Enrollment profile name
82    ///
83    /// # Returns
84    ///
85    /// DER-encoded issued certificate on success.
86    pub async fn route_enrollment(
87        &self,
88        csr_der: &[u8],
89        profile: &str,
90    ) -> Result<Vec<u8>, CaBackendError> {
91        let mut attempts = 0u32;
92        let mut last_error = None;
93
94        while attempts <= self.config.max_retries {
95            let ca = self
96                .ha_pool
97                .select()
98                .ok_or(CaBackendError::NoHealthyBackend)?;
99
100            debug!(
101                ca_id = %ca.id,
102                attempt = attempts + 1,
103                profile = %profile,
104                "routing enrollment to CA"
105            );
106
107            let start = Instant::now();
108            match self
109                .send_to_ca(&ca.id, &ca.endpoint, csr_der, profile)
110                .await
111            {
112                Ok(cert_der) => {
113                    let elapsed = start.elapsed();
114                    self.ha_pool.record_success(&ca.id, elapsed);
115                    info!(
116                        ca_id = %ca.id,
117                        elapsed_ms = elapsed.as_millis(),
118                        "enrollment succeeded"
119                    );
120                    return Ok(cert_der);
121                }
122                Err(e) => {
123                    let elapsed = start.elapsed();
124                    warn!(
125                        ca_id = %ca.id,
126                        attempt = attempts + 1,
127                        elapsed_ms = elapsed.as_millis(),
128                        error = %e,
129                        "enrollment attempt failed"
130                    );
131                    self.ha_pool.record_failure(&ca.id);
132                    last_error = Some(e);
133                }
134            }
135
136            attempts += 1;
137        }
138
139        Err(last_error.unwrap_or(CaBackendError::RetriesExhausted {
140            attempts: self.config.max_retries + 1,
141        }))
142    }
143
144    /// Send a CSR to a specific CA backend.
145    ///
146    /// TODO: implement actual HTTP/CMP request to the CA endpoint.
147    /// For local CAs, this calls `ca::issue::issue_certificate` directly.
148    async fn send_to_ca(
149        &self,
150        ca_id: &CaId,
151        endpoint: &str,
152        _csr_der: &[u8],
153        _profile: &str,
154    ) -> Result<Vec<u8>, CaBackendError> {
155        // Apply request timeout.
156        let result = tokio::time::timeout(self.config.request_timeout, async {
157            // TODO: for remote CAs, issue an HTTP request to `endpoint`.
158            // For local CAs, call the issuance pipeline directly.
159            debug!(
160                ca_id = %ca_id,
161                endpoint = %endpoint,
162                "sending enrollment request (integration pending)"
163            );
164
165            // Placeholder: simulate successful issuance.
166            Ok::<Vec<u8>, CaBackendError>(vec![0x30, 0x00])
167        })
168        .await;
169
170        match result {
171            Ok(inner) => inner,
172            Err(_) => Err(CaBackendError::Timeout {
173                ca_id: ca_id.to_string(),
174                elapsed_ms: self.config.request_timeout.as_millis() as u64,
175            }),
176        }
177    }
178
179    /// Reference to the underlying HA pool.
180    pub fn ha_pool(&self) -> &Arc<CaPool> {
181        &self.ha_pool
182    }
183}