Skip to main content

galata_vault/client/
http.rs

1//! The HTTP transport: ureq on rustls, configured by [`ClientBuilder`].
2//!
3//! Redirects are never followed (a 3xx is a transport error), no proxy is
4//! used unless one is given (ureq would otherwise read `HTTPS_PROXY` and
5//! friends from the environment), and answers are capped at 16 MiB.
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use crate::client::api::Api;
11use crate::client::events::{Events, NoEvents};
12use crate::client::transport::{Method, Request, Response, Transport, TransportError};
13
14/// The default request timeout.
15pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
16
17const MAX_RESPONSE_BYTES: u64 = 16 << 20;
18
19/// A builder setting that was refused.
20#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum BuildError {
23    /// The server URL is not `https://`, or `http://` to a loopback address.
24    InvalidServer(String),
25    /// The proxy URL does not parse.
26    InvalidProxy(String),
27}
28
29impl std::fmt::Display for BuildError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            BuildError::InvalidServer(m) => f.write_str(m),
33            BuildError::InvalidProxy(m) => write!(f, "the proxy URL is not valid: {m}"),
34        }
35    }
36}
37
38impl std::error::Error for BuildError {}
39
40/// How to reach one server. Nothing is taken from the environment.
41///
42/// ```no_run
43/// use std::time::Duration;
44/// use galata_vault::client::ClientBuilder;
45///
46/// let api = ClientBuilder::new("https://vault.example")
47///     .timeout(Duration::from_secs(30))
48///     .user_agent("acme-deploy/1.2")
49///     .build()?;
50/// # let _ = api;
51/// # Ok::<(), galata_vault::client::BuildError>(())
52/// ```
53#[derive(Clone)]
54pub struct ClientBuilder {
55    server: String,
56    timeout: Duration,
57    proxy: Option<String>,
58    roots: Option<Vec<Vec<u8>>>,
59    platform_verifier: bool,
60    user_agent: String,
61    events: Arc<dyn Events>,
62}
63
64impl std::fmt::Debug for ClientBuilder {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("ClientBuilder")
67            .field("server", &self.server)
68            .field("timeout", &self.timeout)
69            .field("proxy", &self.proxy)
70            .field("roots", &self.roots.as_ref().map(Vec::len))
71            .field("platform_verifier", &self.platform_verifier)
72            .field("user_agent", &self.user_agent)
73            .finish_non_exhaustive()
74    }
75}
76
77impl ClientBuilder {
78    /// For `server`: `https://`, or `http://` to a loopback address only,
79    /// checked when the transport is built.
80    pub fn new(server: &str) -> ClientBuilder {
81        ClientBuilder {
82            server: server.to_owned(),
83            timeout: DEFAULT_TIMEOUT,
84            proxy: None,
85            roots: None,
86            platform_verifier: false,
87            user_agent: format!("galata-vault/{}", env!("CARGO_PKG_VERSION")),
88            events: Arc::new(NoEvents),
89        }
90    }
91
92    /// The whole-request timeout (default 120 seconds).
93    pub fn timeout(mut self, timeout: Duration) -> ClientBuilder {
94        self.timeout = timeout;
95        self
96    }
97
98    /// Send every request through this HTTP proxy (`http://host:port`), or
99    /// none. There is no proxy by default, whatever `HTTPS_PROXY` says.
100    pub fn proxy(mut self, proxy: Option<&str>) -> ClientBuilder {
101        self.proxy = proxy.map(str::to_owned);
102        self
103    }
104
105    /// Trust exactly these root certificates (DER), for a server behind a
106    /// private CA. They replace the bundled Mozilla roots: ureq has no
107    /// additive mode, so list every root the server's chain may need.
108    pub fn root_certificates(mut self, roots: Vec<Vec<u8>>) -> ClientBuilder {
109        self.roots = Some(roots);
110        self
111    }
112
113    /// Verify with the operating system's trust store instead of the
114    /// bundled roots.
115    #[cfg(feature = "platform-verifier")]
116    pub fn platform_verifier(mut self, on: bool) -> ClientBuilder {
117        self.platform_verifier = on;
118        self
119    }
120
121    /// The `User-Agent` (default `galata-vault/<version>`).
122    pub fn user_agent(mut self, user_agent: impl Into<String>) -> ClientBuilder {
123        self.user_agent = user_agent.into();
124        self
125    }
126
127    /// Where the handles built on this client report expiry, progress and
128    /// warnings (default: nowhere).
129    pub fn events(mut self, events: Arc<dyn Events>) -> ClientBuilder {
130        self.events = events;
131        self
132    }
133
134    /// The HTTP transport alone.
135    pub fn build_transport(&self) -> Result<HttpTransport, BuildError> {
136        let server = crate::proto::url::validate_server_url(&self.server)
137            .map_err(BuildError::InvalidServer)?;
138        let proxy = match &self.proxy {
139            Some(p) => {
140                Some(ureq::Proxy::new(p).map_err(|e| BuildError::InvalidProxy(e.to_string()))?)
141            }
142            None => None,
143        };
144        let roots = if self.platform_verifier {
145            ureq::tls::RootCerts::PlatformVerifier
146        } else {
147            match &self.roots {
148                Some(roots) => {
149                    let certs: Vec<ureq::tls::Certificate<'static>> = roots
150                        .iter()
151                        .map(|der| ureq::tls::Certificate::from_der(der).to_owned())
152                        .collect();
153                    ureq::tls::RootCerts::new_with_certs(&certs)
154                }
155                None => ureq::tls::RootCerts::WebPki,
156            }
157        };
158        let agent: ureq::Agent = ureq::Agent::config_builder()
159            .http_status_as_error(false)
160            .max_redirects(0)
161            .timeout_global(Some(self.timeout))
162            // Explicitly: ureq's default reads the proxy from the environment.
163            .proxy(proxy)
164            .user_agent(self.user_agent.as_str())
165            .tls_config(ureq::tls::TlsConfig::builder().root_certs(roots).build())
166            .build()
167            .into();
168        Ok(HttpTransport { agent, server })
169    }
170
171    /// The typed API over the HTTP transport, reporting to this builder's
172    /// observer.
173    pub fn build(&self) -> Result<Api, BuildError> {
174        Ok(Api::new(self.build_transport()?).with_events(self.events.clone()))
175    }
176}
177
178/// Requests to one server over HTTP. Cheap to clone: the connection pool is
179/// shared.
180#[derive(Clone)]
181pub struct HttpTransport {
182    agent: ureq::Agent,
183    server: String,
184}
185
186impl std::fmt::Debug for HttpTransport {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct("HttpTransport")
189            .field("server", &self.server)
190            .finish_non_exhaustive()
191    }
192}
193
194impl HttpTransport {
195    /// The server, without a trailing slash.
196    pub fn server(&self) -> &str {
197        &self.server
198    }
199
200    fn fail(&self, e: ureq::Error) -> TransportError {
201        match e {
202            ureq::Error::Io(io) => TransportError::with_source(&self.server, io),
203            other => TransportError::with_source(&self.server, other),
204        }
205    }
206}
207
208fn with_headers<B>(
209    mut rb: ureq::RequestBuilder<B>,
210    request: &Request<'_>,
211) -> ureq::RequestBuilder<B> {
212    if let Some(a) = request.authorization {
213        rb = rb.header("authorization", a);
214    }
215    if let Some(v) = request.if_match {
216        rb = rb.header("if-match", v);
217    }
218    if let Some(v) = request.if_none_match {
219        rb = rb.header("if-none-match", v);
220    }
221    if request.body.is_some() {
222        rb = rb.header("content-type", "application/json");
223    }
224    rb
225}
226
227impl Transport for HttpTransport {
228    fn send(&self, request: Request<'_>) -> Result<Response, TransportError> {
229        let url = format!("{}{}", self.server, request.path_and_query);
230        let payload = request.body.unwrap_or(&[]);
231        let result = match (request.method, request.body) {
232            (Method::Get, _) => with_headers(self.agent.get(&url), &request).call(),
233            (Method::Delete, None) => with_headers(self.agent.delete(&url), &request).call(),
234            // A signed tombstone travels in a DELETE body.
235            (Method::Delete, Some(_)) => {
236                with_headers(self.agent.delete(&url).force_send_body(), &request).send(payload)
237            }
238            (Method::Post, _) => with_headers(self.agent.post(&url), &request).send(payload),
239            (Method::Put, _) => with_headers(self.agent.put(&url), &request).send(payload),
240        };
241        let mut response = result.map_err(|e| self.fail(e))?;
242        let status = response.status().as_u16();
243        if (300..400).contains(&status) {
244            return Err(TransportError::new(
245                &self.server,
246                format!("the server answered {status} with a redirect, which is never followed"),
247            ));
248        }
249        let header = |name: &str| {
250            response
251                .headers()
252                .get(name)
253                .and_then(|v| v.to_str().ok())
254                .map(str::to_owned)
255        };
256        let etag = header("etag");
257        let expires_at = header("x-gv-expires-at");
258        let body = response
259            .body_mut()
260            .with_config()
261            .limit(MAX_RESPONSE_BYTES)
262            .read_to_vec()
263            .map_err(|e| self.fail(e))?;
264        Ok(Response {
265            status,
266            etag,
267            expires_at,
268            body,
269        })
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn server_urls_are_checked_when_built() {
279        assert_eq!(
280            ClientBuilder::new("https://vault.example/")
281                .build_transport()
282                .unwrap()
283                .server(),
284            "https://vault.example"
285        );
286        for bad in [
287            "http://vault.example",
288            "http://127.0.0.1.evil.example",
289            "ftp://x",
290            "https://user@vault.example",
291        ] {
292            assert!(matches!(
293                ClientBuilder::new(bad).build_transport(),
294                Err(BuildError::InvalidServer(_))
295            ));
296        }
297        assert!(matches!(
298            ClientBuilder::new("https://vault.example")
299                .proxy(Some("not a url"))
300                .build_transport(),
301            Err(BuildError::InvalidProxy(_))
302        ));
303    }
304}