Skip to main content

elasticctl_core/
transport.rs

1//! HTTP transport, including URL construction, headers, retries, and error
2//! classification.
3
4use crate::auth::Credential;
5use crate::capabilities::{Capabilities, Feature};
6use crate::config::Profile;
7use crate::error::{Error, ErrorKind, Result};
8use reqwest::{Client, Method, Response, StatusCode};
9use serde_json::Value;
10use std::collections::BTreeMap;
11use std::io::Write;
12use std::time::Duration;
13use tokio::sync::OnceCell;
14
15/// Version of the public API this client targets.
16const API_VERSION: &str = "2023-10-31";
17const MAX_ATTEMPTS: u32 = 3;
18
19/// Response headers retained past the transport boundary.
20///
21/// These headers are allowlisted because recorded fixtures are public.
22/// Capturing all headers could record cookies, rate-limit counters, or future
23/// proxy headers that do not belong in the repository.
24///
25/// The capability probe reads `x-found-handling-cluster`. The other two show
26/// which Cloud headers the recorded response contained.
27const CAPTURED_HEADERS: [&str; 3] = [
28    "x-found-handling-cluster",
29    "x-found-handling-instance",
30    "x-elastic-product",
31];
32
33/// Parse a JSON response without letting serde_json coerce an out-of-range
34/// integer literal into an imprecise floating-point number.
35fn parse_response_json(text: &str) -> Result<Value> {
36    validate_json_integer_ranges(text)?;
37    serde_json::from_str(text)
38        .map_err(|e| Error::new(ErrorKind::Http, format!("parsing response JSON: {e}")))
39}
40
41/// Reject positive integer lexemes above `u64::MAX` and negative integer
42/// lexemes below `i64::MIN`. Numbers with a decimal point or exponent remain
43/// floating-point JSON values, even when their magnitude is greater than
44/// `u64::MAX`.
45fn validate_json_integer_ranges(text: &str) -> Result<()> {
46    let bytes = text.as_bytes();
47    let mut index = 0;
48
49    while index < bytes.len() {
50        if bytes[index] == b'"' {
51            index += 1;
52            while index < bytes.len() {
53                match bytes[index] {
54                    b'\\' => index += 2,
55                    b'"' => {
56                        index += 1;
57                        break;
58                    }
59                    _ => index += 1,
60                }
61            }
62            continue;
63        }
64
65        if bytes[index] != b'-' && !bytes[index].is_ascii_digit() {
66            index += 1;
67            continue;
68        }
69
70        let start = index;
71        if bytes[index] == b'-' {
72            index += 1;
73        }
74        if index == bytes.len() || !bytes[index].is_ascii_digit() {
75            index = start + 1;
76            continue;
77        }
78
79        if bytes[index] == b'0' {
80            index += 1;
81        } else {
82            while index < bytes.len() && bytes[index].is_ascii_digit() {
83                index += 1;
84            }
85        }
86
87        let mut is_integer = true;
88        if bytes.get(index) == Some(&b'.') {
89            is_integer = false;
90            index += 1;
91            while index < bytes.len() && bytes[index].is_ascii_digit() {
92                index += 1;
93            }
94        }
95        if matches!(bytes.get(index), Some(b'e' | b'E')) {
96            is_integer = false;
97            index += 1;
98            if matches!(bytes.get(index), Some(b'+' | b'-')) {
99                index += 1;
100            }
101            while index < bytes.len() && bytes[index].is_ascii_digit() {
102                index += 1;
103            }
104        }
105
106        if is_integer {
107            let number = &text[start..index];
108            let in_range = if bytes[start] == b'-' {
109                number.parse::<i64>().is_ok()
110            } else {
111                number.parse::<u64>().is_ok()
112            };
113            if !in_range {
114                return Err(Error::new(
115                    ErrorKind::Http,
116                    format!(
117                        "parsing response JSON: integer {number} is outside supported integer range"
118                    ),
119                ));
120            }
121        }
122    }
123
124    Ok(())
125}
126
127/// A response body and its captured headers.
128///
129/// Hosted and self-managed stacks return the same `/api/status` body. An
130/// edge-proxy header distinguishes them.
131#[derive(Debug, Clone)]
132pub struct Responded {
133    pub body: Value,
134    pub headers: BTreeMap<String, String>,
135}
136
137impl Responded {
138    /// Look up a header case-insensitively.
139    ///
140    /// The Elastic proxy varies the casing of `x-found-handling-cluster` by
141    /// endpoint.
142    pub fn header(&self, name: &str) -> Option<&str> {
143        self.headers.get(&name.to_ascii_lowercase()).map(|s| &**s)
144    }
145}
146
147/// Percent-encode a query value while leaving URL-safe characters unchanged.
148///
149/// The API client and fixture recorder share this encoder so they produce the
150/// same scoped-filter URL.
151pub fn urlencode(s: &str) -> String {
152    let mut out = String::with_capacity(s.len());
153    for b in s.bytes() {
154        match b {
155            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
156                out.push(b as char)
157            }
158            _ => out.push_str(&format!("%{b:02X}")),
159        }
160    }
161    out
162}
163
164pub struct Transport {
165    client: Client,
166    base: String,
167    /// The Kibana URL exactly as configured, before trailing-slash
168    /// normalization. `doctor` reports it as the connectivity target and the
169    /// capability probe uses it for hostname-based flavor detection.
170    kibana_url: String,
171    /// Elasticsearch host. Cloud deployments use a different host from
172    /// Kibana; otherwise this uses the Kibana host.
173    es_base: String,
174    space: String,
175    auth_header: String,
176    debug: bool,
177    capabilities: OnceCell<Capabilities>,
178}
179
180impl Transport {
181    pub fn new(profile: &Profile) -> Result<Transport> {
182        Self::with_debug(profile, false)
183    }
184
185    /// Build a transport with HTTP request logging enabled or disabled.
186    ///
187    /// Keeping `debug` as a `bool` prevents CLI `clap` types entering `-core`.
188    pub fn with_debug(profile: &Profile, debug: bool) -> Result<Transport> {
189        // Scrub URL userinfo before deriving any base URL or logging, so a
190        // credential embedded in a URL never reaches a request or debug line.
191        let mut profile = profile.clone();
192        profile.strip_userinfo();
193        let credential = Credential::from_profile(&profile)?;
194        let client = Client::builder()
195            .timeout(Duration::from_secs(profile.timeout_secs))
196            .danger_accept_invalid_certs(!profile.verify)
197            .build()
198            .map_err(|e| Error::new(ErrorKind::Connection, format!("building HTTP client: {e}")))?;
199
200        let base = profile.kibana_url.trim_end_matches('/').to_string();
201        let kibana_url = profile.kibana_url.clone();
202        let es_base = profile
203            .es_url
204            .as_deref()
205            .unwrap_or(&profile.kibana_url)
206            .trim_end_matches('/')
207            .to_string();
208
209        Ok(Transport {
210            client,
211            base,
212            kibana_url,
213            es_base,
214            space: profile.space.clone(),
215            auth_header: credential.header_value(),
216            debug,
217            capabilities: OnceCell::new(),
218        })
219    }
220
221    /// Log one request or response line to stderr.
222    ///
223    /// Logs include the method, complete URL, and status. They exclude
224    /// authorization headers and bodies. Callers must not put credentials in
225    /// query strings.
226    fn debug_log(&self, method: &Method, url: &str, status: u16, attempt: u32) {
227        if !self.debug {
228            return;
229        }
230        if attempt > 1 {
231            eprintln!(
232                "[debug] {} {url} -> {status} (attempt {attempt})",
233                method.as_str()
234            );
235        } else {
236            eprintln!("[debug] {} {url} -> {status}", method.as_str());
237        }
238    }
239
240    /// Log the request before sending it so timeouts produce debug output.
241    fn debug_request(&self, method: &Method, url: &str, attempt: u32) {
242        if !self.debug {
243            return;
244        }
245        if attempt > 1 {
246            eprintln!("[debug] -> {} {url} (attempt {attempt})", method.as_str());
247        } else {
248            eprintln!("[debug] -> {} {url}", method.as_str());
249        }
250    }
251
252    /// Log a timeout or connection failure in the response-line format.
253    fn debug_failure(&self, method: &Method, url: &str, what: &str) {
254        if !self.debug {
255            return;
256        }
257        let _ = writeln!(
258            std::io::stderr(),
259            "[debug] {} {url} -> {what}",
260            method.as_str()
261        );
262    }
263
264    /// Prefix non-default spaces with `/s/<name>`.
265    ///
266    /// Kibana serves the default space at the bare path.
267    pub fn space_path(space: &str, path: &str) -> String {
268        if space.is_empty() || space == "default" {
269            path.to_string()
270        } else {
271            format!("/s/{space}{path}")
272        }
273    }
274
275    /// The Kibana URL this transport targets, exactly as configured.
276    pub fn kibana_url(&self) -> &str {
277        &self.kibana_url
278    }
279
280    /// Probe deployment capabilities once for this transport.
281    pub async fn capabilities(&self) -> Result<&Capabilities> {
282        self.capabilities
283            .get_or_try_init(|| Capabilities::probe(self, self.kibana_url()))
284            .await
285    }
286
287    /// Refuse an unverified feature before its public route is called.
288    pub async fn require_feature(&self, feature: Feature) -> Result<()> {
289        self.capabilities().await?.require_feature(feature)
290    }
291
292    fn url(&self, path: &str) -> String {
293        format!("{}{}", self.base, Self::space_path(&self.space, path))
294    }
295
296    /// Read a response body without retrying an operation that may have
297    /// completed after its headers were received.
298    async fn response_text(
299        &self,
300        method: &Method,
301        url: &str,
302        response: Response,
303    ) -> Result<String> {
304        match response.text().await {
305            Ok(text) => Ok(text),
306            Err(e) if e.is_timeout() => {
307                self.debug_failure(method, url, "timeout");
308                Err(Error::new(
309                    ErrorKind::Timeout,
310                    format!("request timed out while reading response body: {e}"),
311                ))
312            }
313            Err(e) => {
314                self.debug_failure(method, url, "connection error");
315                Err(Error::new(
316                    ErrorKind::Connection,
317                    format!("request failed while reading response body: {e}"),
318                ))
319            }
320        }
321    }
322
323    async fn send_retrying<F>(&self, method: Method, url: &str, mut build: F) -> Result<Response>
324    where
325        F: FnMut() -> Result<reqwest::RequestBuilder>,
326    {
327        let mut attempt = 0;
328
329        loop {
330            attempt += 1;
331            let req = build()?;
332
333            self.debug_request(&method, url, attempt);
334            let result = req.send().await;
335
336            let response = match result {
337                Ok(r) => r,
338                Err(e) if e.is_timeout() => {
339                    self.debug_failure(&method, url, "timeout");
340                    return Err(Error::new(
341                        ErrorKind::Timeout,
342                        format!("request timed out: {e}"),
343                    ));
344                }
345                Err(e) => {
346                    self.debug_failure(&method, url, "connection error");
347                    return Err(Error::new(
348                        ErrorKind::Connection,
349                        format!("request failed: {e}"),
350                    ));
351                }
352            };
353
354            let status = response.status();
355            self.debug_log(&method, url, status.as_u16(), attempt);
356            if status.is_success() {
357                return Ok(response);
358            }
359
360            // Retry transient failures only. Retrying a 4xx repeats the same
361            // caller error.
362            let transient = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
363            if transient && attempt < MAX_ATTEMPTS {
364                let backoff = Duration::from_millis(200 * 2u64.pow(attempt - 1));
365                tokio::time::sleep(backoff).await;
366                continue;
367            }
368
369            let code = status.as_u16();
370            let text = self.response_text(&method, url, response).await?;
371            return Err(Error::from_response_body(code, &text));
372        }
373    }
374
375    async fn send(&self, method: Method, path: &str, body: Option<&Value>) -> Result<Response> {
376        let url = self.url(path);
377        let request_method = method.clone();
378        self.send_retrying(method, &url, || {
379            let mut req = self
380                .client
381                .request(request_method.clone(), &url)
382                .header("Authorization", &self.auth_header)
383                .header("elastic-api-version", API_VERSION);
384
385            // Kibana rejects any state-changing request without this header.
386            if request_method != Method::GET {
387                req = req.header("kbn-xsrf", "true");
388            }
389            if let Some(b) = body {
390                req = req.json(b);
391            }
392
393            Ok(req)
394        })
395        .await
396    }
397
398    async fn send_json(&self, method: Method, path: &str, body: Option<&Value>) -> Result<Value> {
399        let url = self.url(path);
400        let response = self.send(method.clone(), path, body).await?;
401        let text = self.response_text(&method, &url, response).await?;
402        if text.trim().is_empty() {
403            return Ok(Value::Null);
404        }
405        parse_response_json(&text)
406    }
407
408    pub async fn get(&self, path: &str) -> Result<Value> {
409        self.send_json(Method::GET, path, None).await
410    }
411
412    /// GET a body with its captured headers.
413    ///
414    /// This is separate from `get` because only the capability probe needs
415    /// headers.
416    pub async fn get_with_headers(&self, path: &str) -> Result<Responded> {
417        let method = Method::GET;
418        let url = self.url(path);
419        let response = self.send(method.clone(), path, None).await?;
420
421        let mut headers = BTreeMap::new();
422        for name in CAPTURED_HEADERS {
423            if let Some(value) = response.headers().get(name)
424                && let Ok(text) = value.to_str()
425            {
426                headers.insert(name.to_string(), text.to_string());
427            }
428        }
429
430        let text = self.response_text(&method, &url, response).await?;
431        let body = if text.trim().is_empty() {
432            Value::Null
433        } else {
434            parse_response_json(&text)?
435        };
436
437        Ok(Responded { body, headers })
438    }
439
440    pub async fn post(&self, path: &str, body: Option<&Value>) -> Result<Value> {
441        self.send_json(Method::POST, path, body).await
442    }
443
444    pub async fn put(&self, path: &str, body: &Value) -> Result<Value> {
445        self.send_json(Method::PUT, path, Some(body)).await
446    }
447
448    pub async fn patch(&self, path: &str, body: &Value) -> Result<Value> {
449        self.send_json(Method::PATCH, path, Some(body)).await
450    }
451
452    pub async fn delete(&self, path: &str) -> Result<Value> {
453        self.send_json(Method::DELETE, path, None).await
454    }
455
456    /// GET Elasticsearch without a Kibana space prefix.
457    ///
458    /// Cloud deployments use a different Elasticsearch host.
459    pub async fn get_absolute_es(&self, path: &str) -> Result<Value> {
460        self.send_absolute_es(Method::GET, path, None).await
461    }
462
463    /// POST JSON to Elasticsearch without a Kibana space prefix or `kbn-xsrf`
464    /// header.
465    pub async fn post_absolute_es(&self, path: &str, body: &Value) -> Result<Value> {
466        self.send_absolute_es(Method::POST, path, Some(body)).await
467    }
468
469    /// DELETE from Elasticsearch.
470    ///
471    /// The fixture recorder uses this to remove its scratch index.
472    pub async fn delete_absolute_es(&self, path: &str) -> Result<Value> {
473        self.send_absolute_es(Method::DELETE, path, None).await
474    }
475
476    /// DELETE from Elasticsearch with a JSON body. The PIT close needs one;
477    /// the plain `delete_absolute_es` sends no body.
478    pub async fn delete_absolute_es_json(&self, path: &str, body: &Value) -> Result<Value> {
479        self.send_absolute_es(Method::DELETE, path, Some(body))
480            .await
481    }
482
483    async fn send_absolute_es(
484        &self,
485        method: Method,
486        path: &str,
487        body: Option<&Value>,
488    ) -> Result<Value> {
489        let url = format!("{}{}", self.es_base, path);
490        let request_method = method.clone();
491        let response = self
492            .send_retrying(method.clone(), &url, || {
493                let mut req = self
494                    .client
495                    .request(request_method.clone(), &url)
496                    .header("Authorization", &self.auth_header);
497                if let Some(b) = body {
498                    req = req.json(b);
499                }
500                Ok(req)
501            })
502            .await?;
503
504        let text = self.response_text(&method, &url, response).await?;
505        if text.trim().is_empty() {
506            return Ok(Value::Null);
507        }
508        parse_response_json(&text)
509    }
510
511    /// POST and return the raw body for NDJSON endpoints.
512    pub async fn post_text(&self, path: &str, body: Option<&Value>) -> Result<String> {
513        let method = Method::POST;
514        let url = self.url(path);
515        let response = self.send(method.clone(), path, body).await?;
516        self.response_text(&method, &url, response).await
517    }
518
519    /// Upload a multipart NDJSON file for Kibana rule import.
520    pub async fn post_multipart_ndjson(&self, path: &str, ndjson: &str) -> Result<Value> {
521        let method = Method::POST;
522        let url = self.url(path);
523        let response = self
524            .send_retrying(method.clone(), &url, || {
525                // Retryable HTTP responses deliberately replay this POST. Part and Form are
526                // recreated here because reqwest consumes multipart bodies while sending.
527                let part = reqwest::multipart::Part::text(ndjson.to_string())
528                    .file_name("rules.ndjson")
529                    .mime_str("application/octet-stream")
530                    .map_err(|e| Error::new(ErrorKind::Error, format!("building upload: {e}")))?;
531                let form = reqwest::multipart::Form::new().part("file", part);
532
533                Ok(self
534                    .client
535                    .post(&url)
536                    .header("Authorization", &self.auth_header)
537                    .header("elastic-api-version", API_VERSION)
538                    .header("kbn-xsrf", "true")
539                    .multipart(form))
540            })
541            .await?;
542
543        let text = self.response_text(&method, &url, response).await?;
544        parse_response_json(&text)
545    }
546}