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    /// Reusable HTTP client for remote CA requests.
66    http_client: reqwest::Client,
67}
68
69impl CaBackendPool {
70    /// Create a new backend pool wrapping the HA pool.
71    pub fn new(ha_pool: Arc<CaPool>, config: CaBackendPoolConfig) -> Self {
72        let http_client = reqwest::Client::builder()
73            .timeout(config.request_timeout)
74            .danger_accept_invalid_certs(false)
75            .pool_max_idle_per_host(4)
76            .build()
77            .expect("failed to build reqwest::Client for CA backend pool");
78        Self {
79            ha_pool,
80            config,
81            http_client,
82        }
83    }
84
85    /// Route a certificate issuance request to a healthy CA.
86    ///
87    /// Selects a CA via the HA strategy, sends the request, and retries
88    /// with the next available CA on failure (up to `max_retries`).
89    ///
90    /// # Arguments
91    ///
92    /// * `csr_der` - DER-encoded CSR to submit
93    /// * `profile` - Enrollment profile name
94    ///
95    /// # Returns
96    ///
97    /// DER-encoded issued certificate on success.
98    pub async fn route_enrollment(
99        &self,
100        csr_der: &[u8],
101        profile: &str,
102    ) -> Result<Vec<u8>, CaBackendError> {
103        let mut attempts = 0u32;
104        let mut last_error = None;
105
106        while attempts <= self.config.max_retries {
107            let ca = self
108                .ha_pool
109                .select()
110                .ok_or(CaBackendError::NoHealthyBackend)?;
111
112            debug!(
113                ca_id = %ca.id,
114                attempt = attempts + 1,
115                profile = %profile,
116                "routing enrollment to CA"
117            );
118
119            let start = Instant::now();
120            match self
121                .send_to_ca(&ca.id, &ca.endpoint, csr_der, profile)
122                .await
123            {
124                Ok(cert_der) => {
125                    let elapsed = start.elapsed();
126                    self.ha_pool.record_success(&ca.id, elapsed);
127                    info!(
128                        ca_id = %ca.id,
129                        elapsed_ms = elapsed.as_millis(),
130                        "enrollment succeeded"
131                    );
132                    return Ok(cert_der);
133                }
134                Err(e) => {
135                    let elapsed = start.elapsed();
136                    warn!(
137                        ca_id = %ca.id,
138                        attempt = attempts + 1,
139                        elapsed_ms = elapsed.as_millis(),
140                        error = %e,
141                        "enrollment attempt failed"
142                    );
143                    self.ha_pool.record_failure(&ca.id);
144                    last_error = Some(e);
145                }
146            }
147
148            attempts += 1;
149        }
150
151        Err(last_error.unwrap_or(CaBackendError::RetriesExhausted {
152            attempts: self.config.max_retries + 1,
153        }))
154    }
155
156    /// Send a CSR to a specific CA backend via EST simple enrollment.
157    ///
158    /// For remote CAs, issues an HTTP POST to the CA's EST endpoint
159    /// (`/.well-known/est/simpleenroll`) per RFC 7030 §4.2.
160    /// The CSR is base64-encoded (DER) and the response is a base64-encoded
161    /// PKCS#7 (`application/pkcs7-mime`) containing the issued certificate.
162    async fn send_to_ca(
163        &self,
164        ca_id: &CaId,
165        endpoint: &str,
166        csr_der: &[u8],
167        profile: &str,
168    ) -> Result<Vec<u8>, CaBackendError> {
169        use base64::Engine as _;
170
171        // Apply request timeout.
172        let result = tokio::time::timeout(self.config.request_timeout, async {
173            debug!(
174                ca_id = %ca_id,
175                endpoint = %endpoint,
176                profile = %profile,
177                csr_len = csr_der.len(),
178                "sending EST simpleenroll request to remote CA"
179            );
180
181            // 1. Base64-encode the DER CSR (RFC 7030 §4.2: base64 of DER PKCS#10).
182            let csr_b64 = base64::engine::general_purpose::STANDARD.encode(csr_der);
183
184            // 2. Build the EST enrollment URL.
185            let url = format!(
186                "{}/.well-known/est/simpleenroll",
187                endpoint.trim_end_matches('/')
188            );
189
190            // 3. POST to the remote CA's EST endpoint (reusing pooled client).
191            let response = self
192                .http_client
193                .post(&url)
194                .header("Content-Type", "application/pkcs10")
195                .header("Content-Transfer-Encoding", "base64")
196                .body(csr_b64)
197                .send()
198                .await
199                .map_err(|e| CaBackendError::BackendError {
200                    ca_id: ca_id.to_string(),
201                    message: format!("HTTP request to {url} failed: {e}"),
202                })?;
203
204            let status = response.status();
205            if !status.is_success() {
206                let body = response.text().await.unwrap_or_default();
207                return Err(CaBackendError::BackendError {
208                    ca_id: ca_id.to_string(),
209                    message: format!("EST enrollment failed: HTTP {status} from {url}: {body}"),
210                });
211            }
212
213            // 4. Read the response body (base64-encoded PKCS#7 / CMS).
214            let body_bytes = response
215                .bytes()
216                .await
217                .map_err(|e| CaBackendError::BackendError {
218                    ca_id: ca_id.to_string(),
219                    message: format!("failed to read EST response body: {e}"),
220                })?;
221
222            // 5. Decode the base64 PKCS#7 response to DER.
223            let cert_der = base64::engine::general_purpose::STANDARD
224                .decode(body_bytes.as_ref())
225                .map_err(|e| CaBackendError::BackendError {
226                    ca_id: ca_id.to_string(),
227                    message: format!("failed to decode base64 PKCS#7 response: {e}"),
228                })?;
229
230            info!(
231                ca_id = %ca_id,
232                cert_len = cert_der.len(),
233                "EST simpleenroll succeeded"
234            );
235
236            Ok(cert_der)
237        })
238        .await;
239
240        match result {
241            Ok(inner) => inner,
242            Err(_) => Err(CaBackendError::Timeout {
243                ca_id: ca_id.to_string(),
244                elapsed_ms: self.config.request_timeout.as_millis() as u64,
245            }),
246        }
247    }
248
249    /// Reference to the underlying HA pool.
250    pub fn ha_pool(&self) -> &Arc<CaPool> {
251        &self.ha_pool
252    }
253}