1use 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#[derive(Debug, Error)]
17pub enum CaBackendError {
18 #[error("no healthy CA backend available")]
20 NoHealthyBackend,
21
22 #[error("request to CA {ca_id} timed out after {elapsed_ms}ms")]
24 Timeout { ca_id: String, elapsed_ms: u64 },
25
26 #[error("all {attempts} retry attempts exhausted")]
28 RetriesExhausted { attempts: u32 },
29
30 #[error("CA backend error from {ca_id}: {message}")]
32 BackendError { ca_id: String, message: String },
33}
34
35#[derive(Debug, Clone)]
37pub struct CaBackendPoolConfig {
38 pub request_timeout: Duration,
40 pub max_retries: u32,
42 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
56pub struct CaBackendPool {
61 ha_pool: Arc<CaPool>,
63 config: CaBackendPoolConfig,
65 http_client: reqwest::Client,
67}
68
69impl CaBackendPool {
70 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 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 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 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 let csr_b64 = base64::engine::general_purpose::STANDARD.encode(csr_der);
183
184 let url = format!(
186 "{}/.well-known/est/simpleenroll",
187 endpoint.trim_end_matches('/')
188 );
189
190 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 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 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 pub fn ha_pool(&self) -> &Arc<CaPool> {
251 &self.ha_pool
252 }
253}