1use std::time::Duration;
9
10use reqwest::{Certificate, Client, Identity};
11use tracing::{debug, info, warn};
12
13use crate::config::DogtagConfig;
14use crate::{DogtagError, DogtagResult};
15
16pub struct DogtagClient {
17 http: Client,
18 base_url: String,
19 basic_auth: Option<(String, String)>,
20 retry_max: u32,
21 retry_delay: Duration,
22}
23
24impl DogtagClient {
25 pub fn new(config: &DogtagConfig) -> DogtagResult<Self> {
26 let is_http = config.ca_url.scheme() == "http";
27
28 if config.accept_invalid_certs {
29 warn!(
30 "Dogtag client: TLS certificate verification DISABLED (accept_invalid_certs=true). \
31 This is insecure and should only be used in development/testing environments."
32 );
33 }
34
35 let mut builder = Client::builder()
36 .danger_accept_invalid_certs(config.accept_invalid_certs)
37 .cookie_store(true)
38 .redirect(reqwest::redirect::Policy::none())
39 .timeout(Duration::from_secs(config.timeout_secs));
40
41 if !is_http && !config.skip_mtls {
46 let cert_pem = std::fs::read(&config.agent_cert_file)?;
47 let key_pem = std::fs::read(&config.agent_key_file)?;
48 let identity = Identity::from_pkcs8_pem(&cert_pem, &key_pem).map_err(|e| {
49 DogtagError::TlsError(format!("Failed to load agent identity: {e}"))
50 })?;
51 builder = builder.identity(identity);
52 info!("Dogtag client: HTTPS with mTLS agent cert");
53 } else if !is_http {
54 info!("Dogtag client: HTTPS without mTLS (skip_mtls=true, basic auth only)");
55 } else {
56 info!("Dogtag client: HTTP (no TLS)");
57 }
58
59 let ca_pem = std::fs::read(&config.ca_cert_file)?;
60 let ca_cert = Certificate::from_pem(&ca_pem)
61 .map_err(|e| DogtagError::TlsError(format!("Failed to load CA certificate: {e}")))?;
62 builder = builder.add_root_certificate(ca_cert);
63
64 let http = builder
65 .build()
66 .map_err(|e| DogtagError::TlsError(format!("HTTP client build failed: {e:?}")))?;
67
68 let base_url = config.ca_url.as_str().trim_end_matches('/').to_owned();
69
70 let basic_auth = match (&config.username, &config.password) {
72 (Some(user), Some(pass)) => {
73 info!("Dogtag client: basic auth enabled for REST API");
74 Some((user.clone(), pass.clone()))
75 }
76 _ if is_http => {
77 tracing::error!("Dogtag HTTP mode requires explicit username/password in config");
78 return Err(DogtagError::ConfigError(
79 "username and password are required for HTTP connections".into(),
80 ));
81 }
82 _ => None,
83 };
84
85 Ok(Self {
86 http,
87 base_url,
88 basic_auth,
89 retry_max: config.retry_max,
90 retry_delay: Duration::from_millis(config.retry_delay_ms),
91 })
92 }
93
94 pub async fn health_check(&self) -> DogtagResult<bool> {
95 for path in ["/pki/v2/info", "/ca/rest/info"] {
97 let url = format!("{}{}", self.base_url, path);
98 debug!(url = %url, "Dogtag health check");
99 match self.do_get(&url).await {
100 Ok(resp) if resp.status().is_success() => return Ok(true),
101 _ => continue,
102 }
103 }
104 Ok(false)
105 }
106
107 pub(crate) async fn get(&self, path: &str) -> DogtagResult<reqwest::Response> {
108 let url = format!("{}{}", self.base_url, path);
109 self.request_with_retry(|| self.do_get(&url)).await
110 }
111
112 pub(crate) async fn post_json<T: serde::Serialize + ?Sized>(
113 &self,
114 path: &str,
115 body: &T,
116 ) -> DogtagResult<reqwest::Response> {
117 let url = format!("{}{}", self.base_url, path);
118 self.do_post_json(&url, body)
119 .await
120 .map_err(|e| DogtagError::HttpError(e.to_string()))
121 }
122
123 pub(crate) async fn post_bytes(
124 &self,
125 path: &str,
126 body: Vec<u8>,
127 content_type: &str,
128 ) -> DogtagResult<reqwest::Response> {
129 let url = format!("{}{}", self.base_url, path);
130 let ct = content_type.to_owned();
131 let mut req = self.http.post(&url).header("Content-Type", &ct).body(body);
132 if let Some((ref user, ref pass)) = self.basic_auth {
133 req = req.basic_auth(user, Some(pass));
134 }
135 req.send()
137 .await
138 .map_err(|e| DogtagError::HttpError(e.to_string()))
139 }
140
141 pub(crate) async fn get_raw(&self, path: &str) -> DogtagResult<reqwest::Response> {
142 let url = format!("{}{}", self.base_url, path);
143 self.request_with_retry(|| {
144 let mut req = self.http.get(&url);
145 if let Some((ref user, ref pass)) = self.basic_auth {
146 req = req.basic_auth(user, Some(pass));
147 }
148 req.send()
149 })
150 .await
151 }
152
153 fn do_get(
154 &self,
155 url: &str,
156 ) -> impl std::future::Future<Output = reqwest::Result<reqwest::Response>> + '_ {
157 let mut req = self.http.get(url).header("Accept", "application/json");
158 if let Some((ref user, ref pass)) = self.basic_auth {
159 req = req.basic_auth(user, Some(pass));
160 }
161 req.send()
162 }
163
164 fn do_post_json<'a, T: serde::Serialize + ?Sized>(
165 &'a self,
166 url: &'a str,
167 body: &'a T,
168 ) -> impl std::future::Future<Output = reqwest::Result<reqwest::Response>> + 'a {
169 let mut req = self
170 .http
171 .post(url)
172 .header("Accept", "application/json")
173 .json(body);
174 if let Some((ref user, ref pass)) = self.basic_auth {
175 req = req.basic_auth(user, Some(pass));
176 }
177 req.send()
178 }
179
180 async fn request_with_retry<F, Fut>(&self, make_request: F) -> DogtagResult<reqwest::Response>
181 where
182 F: Fn() -> Fut,
183 Fut: std::future::Future<Output = reqwest::Result<reqwest::Response>>,
184 {
185 let mut last_error = None;
186
187 for attempt in 0..=self.retry_max {
188 if attempt > 0 {
189 debug!(attempt, max = self.retry_max, "Retrying request");
190 tokio::time::sleep(self.retry_delay).await;
191 }
192
193 match make_request().await {
194 Ok(resp) if resp.status().is_server_error() => {
195 let status = resp.status();
196 let body = crate::bounded_text(resp).await?;
197 warn!(
198 attempt,
199 status = status.as_u16(),
200 "Server error, will retry"
201 );
202 last_error = Some(DogtagError::ApiError {
203 status: status.as_u16(),
204 body,
205 });
206 }
207 Ok(resp) => return Ok(resp),
208 Err(e) => {
209 warn!(attempt, error = %e, "Request failed, will retry");
210 last_error = Some(DogtagError::HttpError(e.to_string()));
211 }
212 }
213 }
214
215 Err(last_error.unwrap_or(DogtagError::ApiError {
216 status: 0,
217 body: "All retry attempts exhausted".into(),
218 }))
219 }
220
221 pub fn base_url(&self) -> &str {
222 &self.base_url
223 }
224
225 pub(crate) async fn json_response<T: serde::de::DeserializeOwned>(
226 resp: reqwest::Response,
227 ) -> DogtagResult<T> {
228 let status = resp.status();
229 let content_type = resp
230 .headers()
231 .get(reqwest::header::CONTENT_TYPE)
232 .and_then(|v| v.to_str().ok())
233 .unwrap_or("unknown")
234 .to_owned();
235
236 let body = crate::bounded_text(resp).await?;
237
238 if !status.is_success() {
239 return Err(DogtagError::ApiError {
240 status: status.as_u16(),
241 body,
242 });
243 }
244
245 tracing::debug!(
246 body_len = body.len(),
247 content_type = %content_type,
248 "parsing Dogtag response"
249 );
250
251 serde_json::from_str(&body).map_err(|e| {
252 let preview = crate::truncate_str(&body, 500);
253 DogtagError::ParseError(format!(
254 "JSON parse failed (content-type: {content_type}): {e}; body: {preview}"
255 ))
256 })
257 }
258}
259
260#[cfg(test)]
261mod review_regressions {
262 use super::*;
263 #[tokio::test]
264 async fn mutations_are_not_replayed_after_server_failure() {
265 use std::sync::{
266 Arc,
267 atomic::{AtomicUsize, Ordering},
268 };
269 use tokio::io::{AsyncReadExt, AsyncWriteExt};
270 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
271 let address = listener.local_addr().unwrap();
272 let count = Arc::new(AtomicUsize::new(0));
273 let count_task = count.clone();
274 let server = tokio::spawn(async move {
275 loop {
276 let (mut stream, _) = listener.accept().await.unwrap();
277 let mut request = [0; 4096];
278 let received = stream.read(&mut request).await.unwrap();
279 assert!(received > 0);
280 count_task.fetch_add(1, Ordering::SeqCst);
281 stream.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap();
282 }
283 });
284 let client = DogtagClient {
285 http: Client::new(),
286 base_url: format!("http://{address}"),
287 basic_auth: None,
288 retry_max: 3,
289 retry_delay: Duration::from_millis(1),
290 };
291 assert_eq!(
292 client
293 .post_json("/issue", &serde_json::json!({}))
294 .await
295 .unwrap()
296 .status(),
297 503
298 );
299 assert_eq!(
300 client
301 .post_bytes("/cmc", vec![1], "application/pkcs7-mime")
302 .await
303 .unwrap()
304 .status(),
305 503
306 );
307 assert_eq!(count.load(Ordering::SeqCst), 2);
308 server.abort();
309 }
310}