Skip to main content

microvms_core/session/
http.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The HTTP seam: one request/response shape, two backends.
3//!
4//! # Why a trait rather than reqwest directly
5//!
6//! Everything worth testing about this client sits *above* HTTP — whether both proxy
7//! headers went out, whether a mint happened inside the retry path, whether a
8//! reconnect resumed at the right byte. reqwest opens real sockets, so none of those
9//! are reachable from a test that does not stand up a server, and the two that matter
10//! most are only reachable by inspecting a request that was already sent.
11//!
12//! So [`HttpBackend`] is the seam. Production is [`ReqwestBackend`]; a test supplies a
13//! recorder that keeps every request head, or a backend that writes bytes at a
14//! simulated network. The daemon made the mirror-image choice with
15//! `axum::serve::Listener`, and for the same reason: the simulator stays out of the
16//! shipping artifact.
17//!
18//! # Streaming is a separate method
19//!
20//! [`HttpBackend::send`] collects the whole body, which for an SSE attach means it
21//! returns once the command is over — and every property worth checking about a stream
22//! is only observable partway through. [`HttpBackend::open_stream`] hands back a chunk
23//! source instead.
24
25use std::collections::HashMap;
26use std::fmt;
27use std::sync::Arc;
28use std::time::Duration;
29
30use futures_util::future::BoxFuture;
31
32use crate::error::{Error, WireKind};
33
34/// One request, already fully addressed: absolute URL, every header, whole body.
35///
36/// Owned rather than borrowed because a backend may need to move it into a spawned
37/// task, and a recorder needs to keep it after the call returns.
38#[derive(Clone)]
39pub struct HttpRequest {
40    pub method: &'static str,
41    /// Path plus query string. Not a full URL: the base is the backend's, so a
42    /// rebound endpoint changes one field rather than every call site.
43    pub path: String,
44    pub headers: Vec<(String, String)>,
45    pub body: Vec<u8>,
46    /// How long the whole exchange may take. `None` means the backend's default.
47    pub timeout: Option<Duration>,
48}
49
50impl HttpRequest {
51    pub fn new(method: &'static str, path: impl Into<String>) -> Self {
52        Self {
53            method,
54            path: path.into(),
55            headers: Vec::new(),
56            body: Vec::new(),
57            timeout: None,
58        }
59    }
60
61    /// One header's value, matched case-insensitively.
62    pub fn header(&self, name: &str) -> Option<&str> {
63        self.headers
64            .iter()
65            .find(|(key, _)| key.eq_ignore_ascii_case(name))
66            .map(|(_, value)| value.as_str())
67    }
68}
69
70/// Names the headers, never their values, and sizes the body rather than printing it.
71///
72/// Every request on this seam carries `authorization: Bearer <agent token>`, and a
73/// streaming attach also carries the minted `X-aws-proxy-auth` — so a derived `Debug` puts
74/// two credentials in any log line, error chain, or test-failure message that formats a
75/// request. Redacting *all* header values rather than allowlisting the two known ones,
76/// because an allowlist is a list somebody has to remember to extend the next time the
77/// platform adds a header, and the platform's stated reason for a token *map* is that it
78/// may need more than one.
79///
80/// The body is sized rather than shown for the same reason: an upload body is file
81/// contents, a stdin body is base64 of whatever the caller is feeding a child, and neither
82/// belongs in a log.
83impl fmt::Debug for HttpRequest {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        let names: Vec<&str> = self.headers.iter().map(|(name, _)| name.as_str()).collect();
86        f.debug_struct("HttpRequest")
87            .field("method", &self.method)
88            .field("path", &self.path)
89            .field("headers", &names)
90            .field("body_len", &self.body.len())
91            .field("timeout", &self.timeout)
92            .finish()
93    }
94}
95
96/// One response, body already collected.
97#[derive(Clone, Debug)]
98pub struct HttpResponse {
99    pub status: u16,
100    pub headers: HashMap<String, String>,
101    pub body: Vec<u8>,
102}
103
104impl HttpResponse {
105    /// The typed error for this response, or `Ok` for a 2xx.
106    ///
107    /// Every non-2xx goes through here, so the status-to-[`WireKind`] table is applied
108    /// in exactly one place. A status the daemon never chooses becomes a
109    /// [`WireKind::ServerError`]-free plain protocol failure rather than being mapped
110    /// to the nearest neighbour — see [`crate::error::WireKind::from_status`] on why
111    /// there is no generic 4xx fallback.
112    pub fn error_for_status(&self, method: &str, path: &str) -> Result<(), Error> {
113        if (200..300).contains(&self.status) {
114            return Ok(());
115        }
116        // Capped at 512 bytes, as the Python does: a detail string is for a human
117        // reading a log, and a 256 MB error body in a message is its own incident.
118        let detail = String::from_utf8_lossy(&self.body[..self.body.len().min(512)])
119            .trim()
120            .to_string();
121        let message = if detail.is_empty() {
122            format!("{method} {path} -> {}", self.status)
123        } else {
124            format!("{method} {path} -> {}: {detail}", self.status)
125        };
126        match crate::error::WireKind::from_status(self.status) {
127            Some(wire) => Err(Error::wire(wire, message)),
128            // A status outside the daemon's vocabulary. `Protocol` rather than
129            // `Retryable`, because nothing says a retry would land differently, and
130            // rather than a specific wire kind, because inventing one would be this
131            // client claiming to know a meaning the daemon never assigned.
132            None => Err(Error::new(crate::error::ErrorKind::Protocol, message)),
133        }
134    }
135}
136
137/// A body arriving in pieces.
138///
139/// One method, because that is all a cursor-driven stream needs: `Ok(None)` is the end
140/// of the body, however it ended. Which *way* it ended is not this trait's business —
141/// the protocol answers that with the presence or absence of a terminal `exit` event,
142/// which is exactly why the transport is framed.
143pub trait ChunkSource: Send {
144    fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>>;
145}
146
147/// A streaming response: the head, and the body still to arrive.
148///
149/// Named rather than spelled inline at [`HttpBackend::open_stream`], because the two
150/// halves are one thing — the status has to be readable before the first body byte, so a
151/// backend cannot hand back one without the other.
152pub type OpenStream = (HttpResponse, Box<dyn ChunkSource>);
153
154/// The seam. Production and simulated transports implement this.
155pub trait HttpBackend: Send + Sync {
156    /// Sends one request and collects the whole body, whatever the status.
157    ///
158    /// Deliberately does not fail on a status: a conformance suite asserts on 401 and
159    /// 409 as expected outcomes, and a client that could only reach them through an
160    /// error would be a client that cannot test the protocol.
161    fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>>;
162
163    /// Opens a streaming response, reading the status before any body byte.
164    ///
165    /// Status first so a 404 on an unknown exec id surfaces as `NotFound` rather than
166    /// as an empty stream. `idle_timeout` bounds *silence* rather than duration: an SSE
167    /// body is idle by design between chunks, so the useful bound is how long a gap may
168    /// last. Without one, a half-open connection — the failure a NAT or proxy timeout
169    /// produces, where no FIN ever arrives — hangs forever and the reconnect logic
170    /// never runs.
171    fn open_stream(
172        &self,
173        request: HttpRequest,
174        idle_timeout: Duration,
175    ) -> BoxFuture<'_, Result<OpenStream, Error>>;
176}
177
178/// The production backend: one pooled reqwest client.
179///
180/// Pooled rather than a client per request, because the daemon drains a bounded prefix
181/// of a rejected body specifically so pooled connections keep working, and throwing the
182/// pool away per request discards that.
183pub struct ReqwestBackend {
184    client: reqwest::Client,
185    base_url: String,
186    timeout: Duration,
187}
188
189impl ReqwestBackend {
190    /// A backend rooted at `base_url`.
191    ///
192    /// A bare host is read as `https`. The endpoint the platform hands back is a
193    /// hostname, and defaulting to plain HTTP there would send a bearer token in
194    /// clear text on the strength of a missing prefix.
195    pub fn new(base_url: &str, timeout: Duration) -> Result<Self, Error> {
196        let client = reqwest::Client::builder()
197            .timeout(timeout)
198            .build()
199            .map_err(|err| {
200                Error::new(
201                    crate::error::ErrorKind::Unexpected,
202                    format!("could not build an HTTP client: {err}"),
203                )
204                .with_source(err)
205            })?;
206        Ok(Self {
207            client,
208            base_url: normalize_base_url(base_url),
209            timeout,
210        })
211    }
212
213    fn url(&self, path: &str) -> String {
214        format!("{}{path}", self.base_url)
215    }
216
217    fn build(&self, request: &HttpRequest) -> reqwest::RequestBuilder {
218        let mut builder = self
219            .client
220            .request(
221                reqwest::Method::from_bytes(request.method.as_bytes())
222                    .unwrap_or(reqwest::Method::GET),
223                self.url(&request.path),
224            )
225            .timeout(request.timeout.unwrap_or(self.timeout));
226        for (name, value) in &request.headers {
227            builder = builder.header(name, value);
228        }
229        if !request.body.is_empty() {
230            builder = builder.body(request.body.clone());
231        }
232        builder
233    }
234}
235
236/// A bare host becomes `https://`, and a trailing slash is dropped so a path can be
237/// concatenated without producing a double one.
238fn normalize_base_url(base_url: &str) -> String {
239    let with_scheme = if base_url.starts_with("http://") || base_url.starts_with("https://") {
240        base_url.to_string()
241    } else {
242        format!("https://{base_url}")
243    };
244    with_scheme.trim_end_matches('/').to_string()
245}
246
247/// A transport failure. Retryable, because it says nothing about the daemon's state.
248fn transport_error(method: &str, path: &str, err: reqwest::Error) -> Error {
249    Error::wire(
250        WireKind::Transport,
251        format!("{method} {path} failed on the wire: {err}"),
252    )
253    .with_source(err)
254}
255
256fn collect_headers(response: &reqwest::Response) -> HashMap<String, String> {
257    response
258        .headers()
259        .iter()
260        .map(|(name, value)| {
261            (
262                name.as_str().to_string(),
263                String::from_utf8_lossy(value.as_bytes()).into_owned(),
264            )
265        })
266        .collect()
267}
268
269impl HttpBackend for ReqwestBackend {
270    fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
271        Box::pin(async move {
272            let response = self
273                .build(&request)
274                .send()
275                .await
276                .map_err(|err| transport_error(request.method, &request.path, err))?;
277            let status = response.status().as_u16();
278            let headers = collect_headers(&response);
279            let body = response
280                .bytes()
281                .await
282                .map_err(|err| transport_error(request.method, &request.path, err))?
283                .to_vec();
284            Ok(HttpResponse {
285                status,
286                headers,
287                body,
288            })
289        })
290    }
291
292    fn open_stream(
293        &self,
294        request: HttpRequest,
295        idle_timeout: Duration,
296    ) -> BoxFuture<'_, Result<OpenStream, Error>> {
297        Box::pin(async move {
298            let response = self
299                .build(&request)
300                // No overall timeout on a stream: the bound is `idle_timeout`, applied
301                // per chunk below. An overall one would cut a healthy long-running
302                // command off mid-output.
303                .timeout(Duration::MAX)
304                .send()
305                .await
306                .map_err(|err| transport_error(request.method, &request.path, err))?;
307            let status = response.status().as_u16();
308            let headers = collect_headers(&response);
309
310            // The status is read before any body byte. On a failure the body is
311            // collected so the typed error carries the daemon's detail string.
312            if !(200..300).contains(&status) {
313                let body = response
314                    .bytes()
315                    .await
316                    .map_err(|err| transport_error(request.method, &request.path, err))?
317                    .to_vec();
318                return Ok((
319                    HttpResponse {
320                        status,
321                        headers,
322                        body,
323                    },
324                    Box::new(EmptyChunks) as Box<dyn ChunkSource>,
325                ));
326            }
327
328            let head = HttpResponse {
329                status,
330                headers,
331                body: Vec::new(),
332            };
333            let chunks = ReqwestChunks {
334                response: Some(response),
335                idle_timeout,
336                method: request.method,
337                path: request.path,
338            };
339            Ok((head, Box::new(chunks) as Box<dyn ChunkSource>))
340        })
341    }
342}
343
344/// No body at all, for the failure path where the head already carries everything.
345struct EmptyChunks;
346
347impl ChunkSource for EmptyChunks {
348    fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
349        Box::pin(async { Ok(None) })
350    }
351}
352
353struct ReqwestChunks {
354    response: Option<reqwest::Response>,
355    idle_timeout: Duration,
356    method: &'static str,
357    path: String,
358}
359
360impl ChunkSource for ReqwestChunks {
361    fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
362        Box::pin(async move {
363            let Some(response) = self.response.as_mut() else {
364                return Ok(None);
365            };
366            match tokio::time::timeout(self.idle_timeout, response.chunk()).await {
367                Ok(Ok(Some(bytes))) => Ok(Some(bytes.to_vec())),
368                Ok(Ok(None)) => {
369                    self.response = None;
370                    Ok(None)
371                }
372                Ok(Err(err)) => {
373                    self.response = None;
374                    Err(transport_error(self.method, &self.path, err))
375                }
376                Err(_) => {
377                    self.response = None;
378                    // Retryable by construction: silence past the keepalive interval
379                    // means the connection is dead, and the exec is untouched.
380                    Err(Error::wire(
381                        WireKind::Transport,
382                        format!(
383                            "{} {} went silent for {}s, longer than the keepalive \
384                             interval, so the connection is treated as dead",
385                            self.method,
386                            self.path,
387                            self.idle_timeout.as_secs()
388                        ),
389                    ))
390                }
391            }
392        })
393    }
394}
395
396impl fmt::Debug for ReqwestBackend {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        f.debug_struct("ReqwestBackend")
399            .field("base_url", &self.base_url)
400            .field("timeout", &self.timeout)
401            .finish()
402    }
403}
404
405/// A backend behind an `Arc`, which is what a [`crate::session::Session`] holds.
406pub type SharedBackend = Arc<dyn HttpBackend>;
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::error::ErrorKind;
412
413    /// A bare host is `https`, because the platform hands back a hostname and plain
414    /// HTTP there would put a bearer token on the wire in clear text.
415    #[test]
416    fn a_bare_endpoint_host_is_read_as_https() {
417        assert_eq!(
418            normalize_base_url("vm-abc.microvms.aws"),
419            "https://vm-abc.microvms.aws"
420        );
421        assert_eq!(
422            normalize_base_url("http://127.0.0.1:9000"),
423            "http://127.0.0.1:9000",
424            "an explicit scheme is honoured, so a local daemon is still reachable"
425        );
426        assert_eq!(
427            normalize_base_url("https://host/"),
428            "https://host",
429            "a trailing slash would produce a double one on every path"
430        );
431    }
432
433    /// Each status the daemon chooses becomes its own variant, and an unmapped 4xx
434    /// becomes none of them.
435    #[test]
436    fn a_response_status_becomes_the_wire_kind_the_daemon_meant() {
437        let response = |status: u16| HttpResponse {
438            status,
439            headers: HashMap::new(),
440            body: b"{\"error\":\"unknown_exec\",\"detail\":\"e1\"}".to_vec(),
441        };
442        assert!(response(200).error_for_status("GET", "/v1/health").is_ok());
443
444        let err = response(404)
445            .error_for_status("GET", "/v1/exec/e1")
446            .expect_err("404 is an error");
447        assert_eq!(err.wire_kind(), Some(WireKind::NotFound));
448        assert!(
449            err.to_string().contains("unknown_exec"),
450            "the daemon's detail must survive into the message: {err}"
451        );
452
453        let err = response(429)
454            .error_for_status("GET", "/v1/health")
455            .expect_err("an unmapped status is still an error");
456        assert_eq!(err.kind(), ErrorKind::Protocol);
457        assert_eq!(
458            err.wire_kind(),
459            None,
460            "a status the daemon never chooses must not be given an invented meaning"
461        );
462    }
463
464    /// A body long enough to be its own problem is truncated in the message.
465    #[test]
466    fn an_enormous_error_body_is_capped_in_the_message() {
467        let response = HttpResponse {
468            status: 500,
469            headers: HashMap::new(),
470            body: vec![b'x'; 100_000],
471        };
472        let err = response
473            .error_for_status("PUT", "/v1/fs/file")
474            .expect_err("500 is an error");
475        assert!(err.to_string().len() < 700, "{}", err.to_string().len());
476    }
477
478    /// Header lookup on a request is case-insensitive, since a caller may spell a
479    /// header either way and the proxy compares insensitively.
480    #[test]
481    fn a_request_header_is_found_whatever_case_it_was_set_in() {
482        let mut request = HttpRequest::new("GET", "/v1/health");
483        request
484            .headers
485            .push(("X-Aws-Proxy-Port".into(), "9000".into()));
486        assert_eq!(request.header("x-aws-proxy-port"), Some("9000"));
487        assert_eq!(request.header("authorization"), None);
488    }
489
490    /// A request's `Debug` names its headers and never their values, so neither the agent
491    /// token nor the minted proxy token reaches a log line or a test-failure message.
492    ///
493    /// **Falsification** — restore `#[derive(Debug)]` on [`HttpRequest`] and both value
494    /// assertions fail; drop the body from the redaction and the last one fails.
495    #[test]
496    fn a_request_debug_prints_header_names_and_never_their_values() {
497        let mut request = HttpRequest::new("POST", "/v1/exec/e1/stdin");
498        request.headers.push((
499            "authorization".into(),
500            "Bearer super-secret-agent-token".into(),
501        ));
502        request
503            .headers
504            .push(("X-aws-proxy-auth".into(), "eyJhbGciOi-secret-jwe".into()));
505        request.body = b"secret-body-bytes".to_vec();
506        request.timeout = Some(Duration::from_secs(7));
507
508        let rendered = format!("{request:?}");
509        assert!(rendered.contains("authorization"), "{rendered}");
510        assert!(rendered.contains("X-aws-proxy-auth"), "{rendered}");
511        assert!(rendered.contains("/v1/exec/e1/stdin"), "{rendered}");
512        assert!(rendered.contains("POST"), "{rendered}");
513        assert!(rendered.contains('7'), "the timeout survives: {rendered}");
514        assert!(
515            rendered.contains("body_len"),
516            "the body's size is diagnostic: {rendered}"
517        );
518        assert!(
519            !rendered.contains("super-secret-agent-token"),
520            "the agent token reached a Debug string: {rendered}"
521        );
522        assert!(
523            !rendered.contains("eyJhbGciOi-secret-jwe"),
524            "the minted proxy token reached a Debug string: {rendered}"
525        );
526        assert!(
527            !rendered.contains("secret-body-bytes"),
528            "the body reached a Debug string: {rendered}"
529        );
530    }
531}