Skip to main content

kipuka_dogtag/
client.rs

1//! HTTP client for the Dogtag CA REST API.
2//!
3//! Provides [`DogtagClient`], the core HTTP client that handles mTLS
4//! authentication, request retry, and JSON response parsing for all
5//! Dogtag REST API interactions.
6
7use std::time::Duration;
8
9use reqwest::{Certificate, Client, Identity};
10use tracing::{debug, warn};
11
12use crate::config::DogtagConfig;
13use crate::{DogtagError, DogtagResult};
14
15/// HTTP client for Dogtag CA REST API operations.
16///
17/// Wraps a `reqwest::Client` configured with mTLS agent credentials for
18/// authenticating to the Dogtag PKI REST API. All methods perform automatic
19/// retry on transient failures (HTTP 5xx and connection errors).
20///
21/// # Construction
22///
23/// Use [`DogtagClient::new`] with a [`DogtagConfig`] to build a client.
24/// The agent certificate and key files are read during construction and
25/// the TLS identity is established once for the lifetime of the client.
26///
27/// # Thread Safety
28///
29/// `DogtagClient` is `Send + Sync` and can be shared across async tasks
30/// via `Arc<DogtagClient>`.
31pub struct DogtagClient {
32    http: Client,
33    base_url: String,
34    retry_max: u32,
35    retry_delay: Duration,
36}
37
38impl DogtagClient {
39    /// Create a new Dogtag client from configuration.
40    ///
41    /// Reads the agent certificate, key, and CA certificate files to
42    /// configure mTLS. Returns an error if any file cannot be read or
43    /// if the TLS identity cannot be constructed.
44    pub fn new(config: &DogtagConfig) -> DogtagResult<Self> {
45        let cert_pem = std::fs::read(&config.agent_cert_file)?;
46        let key_pem = std::fs::read(&config.agent_key_file)?;
47        let ca_pem = std::fs::read(&config.ca_cert_file)?;
48
49        // reqwest Identity expects concatenated cert + key PEM.
50        let mut identity_pem = cert_pem;
51        identity_pem.extend_from_slice(b"\n");
52        identity_pem.extend_from_slice(&key_pem);
53
54        let identity = Identity::from_pem(&identity_pem)
55            .map_err(|e| DogtagError::TlsError(format!("Failed to load agent identity: {e}")))?;
56
57        let ca_cert = Certificate::from_pem(&ca_pem)
58            .map_err(|e| DogtagError::TlsError(format!("Failed to load CA certificate: {e}")))?;
59
60        let http = Client::builder()
61            .identity(identity)
62            .add_root_certificate(ca_cert)
63            .timeout(Duration::from_secs(config.timeout_secs))
64            .build()?;
65
66        // Normalize base URL: strip trailing slash.
67        let base_url = config.ca_url.as_str().trim_end_matches('/').to_owned();
68
69        Ok(Self {
70            http,
71            base_url,
72            retry_max: config.retry_max,
73            retry_delay: Duration::from_millis(config.retry_delay_ms),
74        })
75    }
76
77    /// Check Dogtag CA health by querying the info endpoint.
78    ///
79    /// Sends `GET /ca/rest/info` and returns `true` if the CA responds
80    /// with HTTP 200.
81    pub async fn health_check(&self) -> DogtagResult<bool> {
82        let url = format!("{}/ca/rest/info", self.base_url);
83        debug!(url = %url, "Dogtag health check");
84
85        match self.http.get(&url).send().await {
86            Ok(resp) => Ok(resp.status().is_success()),
87            Err(e) => {
88                warn!(error = %e, "Dogtag health check failed");
89                Ok(false)
90            }
91        }
92    }
93
94    /// Send a GET request with retry.
95    pub(crate) async fn get(&self, path: &str) -> DogtagResult<reqwest::Response> {
96        let url = format!("{}{}", self.base_url, path);
97        self.request_with_retry(|| self.http.get(&url).send()).await
98    }
99
100    /// Send a POST request with a JSON body and retry.
101    pub(crate) async fn post_json<T: serde::Serialize + ?Sized>(
102        &self,
103        path: &str,
104        body: &T,
105    ) -> DogtagResult<reqwest::Response> {
106        let url = format!("{}{}", self.base_url, path);
107        self.request_with_retry(|| self.http.post(&url).json(body).send())
108            .await
109    }
110
111    /// Send a POST request with raw bytes and a specific content type.
112    pub(crate) async fn post_bytes(
113        &self,
114        path: &str,
115        body: Vec<u8>,
116        content_type: &str,
117    ) -> DogtagResult<reqwest::Response> {
118        let url = format!("{}{}", self.base_url, path);
119        let ct = content_type.to_owned();
120        self.request_with_retry(|| {
121            self.http
122                .post(&url)
123                .header("Content-Type", &ct)
124                .body(body.clone())
125                .send()
126        })
127        .await
128    }
129
130    /// Execute a request with retry on transient failures.
131    ///
132    /// Retries on HTTP 5xx responses and connection errors, up to
133    /// `retry_max` attempts with a fixed delay between attempts.
134    async fn request_with_retry<F, Fut>(&self, make_request: F) -> DogtagResult<reqwest::Response>
135    where
136        F: Fn() -> Fut,
137        Fut: std::future::Future<Output = reqwest::Result<reqwest::Response>>,
138    {
139        let mut last_error = None;
140
141        for attempt in 0..=self.retry_max {
142            if attempt > 0 {
143                debug!(attempt, max = self.retry_max, "Retrying request");
144                tokio::time::sleep(self.retry_delay).await;
145            }
146
147            match make_request().await {
148                Ok(resp) if resp.status().is_server_error() => {
149                    let status = resp.status();
150                    let body = resp.text().await.unwrap_or_default();
151                    warn!(
152                        attempt,
153                        status = status.as_u16(),
154                        "Server error, will retry"
155                    );
156                    last_error = Some(DogtagError::ApiError {
157                        status: status.as_u16(),
158                        body,
159                    });
160                }
161                Ok(resp) => return Ok(resp),
162                Err(e) => {
163                    warn!(attempt, error = %e, "Request failed, will retry");
164                    last_error = Some(DogtagError::Http(e));
165                }
166            }
167        }
168
169        Err(last_error.unwrap_or(DogtagError::ApiError {
170            status: 0,
171            body: "All retry attempts exhausted".into(),
172        }))
173    }
174
175    /// Return the base URL (for pool routing).
176    pub fn base_url(&self) -> &str {
177        &self.base_url
178    }
179
180    /// Extract a successful JSON response or return an API error.
181    pub(crate) async fn json_response<T: serde::de::DeserializeOwned>(
182        resp: reqwest::Response,
183    ) -> DogtagResult<T> {
184        let status = resp.status();
185        if !status.is_success() {
186            let body = resp.text().await.unwrap_or_default();
187            return Err(DogtagError::ApiError {
188                status: status.as_u16(),
189                body,
190            });
191        }
192        resp.json::<T>()
193            .await
194            .map_err(|e| DogtagError::ParseError(e.to_string()))
195    }
196}