Skip to main content

kipuka_dogtag/
client.rs

1//! HTTP client for the Dogtag CA REST API.
2//!
3//! Uses `reqwest` with `native-tls` (OpenSSL 3.5+) for mTLS agent
4//! authentication on HTTPS. OpenSSL 3.5 supports ML-DSA-87 TLS
5//! SignatureSchemes, enabling mTLS with PQ agent certs. Falls back
6//! to HTTP basic auth when ca_url uses the http scheme.
7
8use 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        // HTTPS: mTLS with agent cert + basic auth for Dogtag REST authorization
42        // HTTP: basic auth only (no TLS)
43        // skip_mtls: HTTPS with basic auth only (for PQ CAs where NSS cannot
44        //   validate ML-DSA-signed agent cert chains during TLS client auth)
45        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        // Basic auth for Dogtag REST API authorization (used on both HTTP and HTTPS)
71        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        // Try v2 first, fall back to v1 for older Dogtag instances
96        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        // A transport failure may occur after commit: never replay a mutation.
136        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}