Skip to main content

microvms_edges/session/
forward.rs

1// SPDX-License-Identifier: Apache-2.0
2//! A local TCP listener that forwards HTTP and WebSocket traffic to one guest port.
3//!
4//! The client half of `microvm port-forward`. A caller binds a local address, and every
5//! connection accepted there is replayed against the VM's endpoint with the two proxy
6//! headers [`ProxyAuth`] mints for the guest port. What arrives at the guest is an ordinary
7//! request from an ordinary client: the platform strips both headers before forwarding
8//! (measured 2026-08-15, `docs/PLATFORM.md`), so a server inside the VM needs no MicroVM
9//! awareness.
10//!
11//! # Why this is in core rather than in the CLI
12//!
13//! `crates/microvms-cli/tests/thinness.rs` forbids `hyper`, `http`, and `reqwest` in the CLI's
14//! dependency set, because each of them is a second path to AWS that does not go through
15//! this crate. A forwarder is an HTTP server, so it cannot live there. Putting it here also
16//! puts it behind the same [`ProxyAuth`] the session's own requests use, which is the
17//! property TRAP-9 rests on: there is one mint schedule, and a long-lived tunnel opened at
18//! minute fifty gets a token minted at minute fifty.
19//!
20//! # Every hop re-reads the header, and that is the refresh
21//!
22//! [`ProxyAuth::headers_for_port`] is called per proxied request rather than once at bind
23//! time. The token's sixty-minute ceiling is therefore crossed by consulting the cache that
24//! already knows about it — no timer here, no expiry arithmetic, and
25//! [`ProxyAuth::mint_count`] is the observable that says a refresh happened. A forwarder
26//! that captured the headers at startup would work for thirty minutes and then return 401s
27//! that read as a wrong agent token.
28//!
29//! # An upgrade is forwarded as bytes, not as a protocol this module understands
30//!
31//! A WebSocket handshake is an ordinary GET whose response is 101. After that, neither end
32//! speaks HTTP, so this module stops parsing and copies bytes in both directions until one
33//! side closes. This code frames nothing after the handshake, so it cannot frame anything
34//! wrongly, and an application subprotocol passes through untouched.
35//!
36//! # The upgrade path does not reach a guest server through the endpoint proxy yet
37//!
38//! Measured 2026-08-29, us-east-1, against a guest RFC 6455 echo server on port 8090. An
39//! upgrade re-issued here as an ordinary HTTPS `GET` — `Upgrade: websocket` and
40//! `Connection: Upgrade` forwarded as request headers — is answered by the **proxy** with
41//! `400` (an `x-amzn-requestid` on the response, and the guest logged no handshake at all,
42//! while the same handshake sent from inside the guest to `127.0.0.1:8090` answers 101).
43//!
44//! The reason is in `docs/PLATFORM.md`: on the endpoint the WebSocket credential travels as
45//! **`Sec-WebSocket-Protocol` values**, not as the two proxy headers, because the browser
46//! `WebSocket` constructor cannot set a header. So a working tunnel has to open a real
47//! `wss://` handshake offering [`microvms_app::session::Session::connect_subprotocols`]'s three
48//! values, rather than replay the client's `GET` over the HTTPS path. That is a genuine
49//! WebSocket client in this module, which the HTTP relay below is not.
50//!
51//! What *is* measured as working on that path, from the same run: a `wss://` handshake
52//! offering the three minted subprotocols reaches the guest, and **binary** frames survive
53//! byte-exact in both directions on a **port-scoped** token — including `0x00`/`0xFF`
54//! payloads that a silent utf-8 round trip would corrupt, and a 300-byte frame that
55//! exercises the extended-length header. The guest observed opcode `2` and no
56//! `sec-websocket-protocol` header, so the proxy consumed all three values as documented.
57//! That is the transport premise the raw-TCP tunnel (issue #70 layer 2) rests on, and it is
58//! also what this module needs in order to carry an upgrade.
59//!
60//! Until that client exists, [`is_upgrade`] and [`splice`] are exercised by the tests below
61//! against a local upstream, where the byte relay is correct — the untested edge is the
62//! handshake negotiation with the platform, not the splice.
63//!
64//! # What a failure says
65//!
66//! The proxy answers **403** for a token whose scope does not cover the port and **502**
67//! when the scope is right and nothing is listening in the guest. That pair is the only
68//! diagnostic separating a scope mistake from a dead server, and on the WebSocket path both
69//! collapse to close code 1006 with no reason. So this module surfaces the distinction from
70//! the HTTP status while it still has one, in [`ForwardEvent::Refused`], rather than letting
71//! a caller rediscover it from a closed socket.
72
73use std::net::SocketAddr;
74use std::sync::Arc;
75
76use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
77use tokio::net::{TcpListener, TcpStream};
78
79use microvms_app::error::{Error, ErrorKind};
80use microvms_app::session::proxy::{PROXY_AUTH_HEADER, PROXY_PORT_HEADER, ProxyAuth};
81
82/// How long one proxied exchange may take before it is abandoned.
83///
84/// Generous rather than tight: the thing on the other end is a developer's dev server, and a
85/// long-polling request or a slow first compile is normal traffic rather than a fault. The
86/// upgrade path is exempt — a WebSocket is idle by design, so once a connection is upgraded
87/// this timeout no longer applies to it.
88pub const DEFAULT_EXCHANGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
89
90/// What the forwarder did, for a caller that wants to report progress.
91///
92/// An enum handed to a callback rather than a log line, because this crate does no logging
93/// and the CLI owns every byte the user sees. The variants are the three things worth
94/// telling somebody watching a tunnel: it is up, a request went through, or the proxy
95/// refused one for a reason worth spelling out.
96#[derive(Clone, Debug)]
97pub enum ForwardEvent {
98    /// The listener is bound and accepting. Carries the address actually bound, which
99    /// differs from the requested one when the caller asked for port 0.
100    Listening { local: SocketAddr, guest_port: u16 },
101    /// One exchange completed with this status.
102    Forwarded { status: u16, upgraded: bool },
103    /// The proxy refused the exchange, with the distinction the status carries.
104    ///
105    /// `403` means the minted token's scope does not cover `guest_port`; `502` means the
106    /// scope is right and nothing is listening there. Anything else is passed through
107    /// unclassified rather than guessed at.
108    Refused { status: u16, explanation: String },
109    /// A connection ended in a way worth mentioning but not worth failing the tunnel for.
110    ConnectionError { detail: String },
111}
112
113/// The 403-vs-502 sentence, or `None` for a status that needs no explaining.
114///
115/// Free function so the wording is tested without standing up a listener, and so the CLI
116/// cannot drift from it by writing its own.
117pub fn refusal_explanation(status: u16, guest_port: u16) -> Option<String> {
118    match status {
119        403 => Some(format!(
120            "the endpoint proxy refused the request for port {guest_port} (403 Access to port \
121             denied): the minted token's scope does not cover it. Nothing was wrong with the \
122             guest — the credential never authorized this port."
123        )),
124        502 => Some(format!(
125            "the endpoint proxy reached the VM and found nothing listening on port \
126             {guest_port} (502). The credential is correct and the scope covers the port, so \
127             this is a dead server in the guest rather than an auth problem."
128        )),
129        401 => Some(
130            "the endpoint proxy rejected the credential (401). On a long-lived tunnel this \
131             usually means a minted token expired without being refreshed; the mint count is \
132             the observable that says whether a refresh happened."
133                .to_string(),
134        ),
135        _ => None,
136    }
137}
138
139/// Copies bytes both ways until one side closes, then returns.
140///
141/// Used for the post-101 tail of an upgraded connection. `copy_bidirectional` rather than
142/// two hand-rolled loops: it already handles the half-close case where one direction ends
143/// while the other still has data, which is the case a naive `select!` loop truncates.
144pub async fn splice<A, B>(a: &mut A, b: &mut B) -> Result<(u64, u64), Error>
145where
146    A: AsyncRead + AsyncWrite + Unpin + ?Sized,
147    B: AsyncRead + AsyncWrite + Unpin + ?Sized,
148{
149    tokio::io::copy_bidirectional(a, b).await.map_err(|err| {
150        Error::new(
151            ErrorKind::Unexpected,
152            format!("the upgraded connection ended abnormally: {err}"),
153        )
154    })
155}
156
157/// Where the forwarder listens and what it forwards to.
158///
159/// A struct rather than four arguments for [`crate::seam`]'s reason: `local_port` and
160/// `guest_port` are both `u16` and both plausible in either position, so a positional call
161/// is how a tunnel ends up pointing at itself.
162#[derive(Clone, Debug)]
163pub struct ForwardSpec {
164    /// The local address to bind. Port 0 asks the OS for a free one, which the
165    /// [`ForwardEvent::Listening`] event then reports.
166    pub bind: SocketAddr,
167    /// The port inside the guest to forward to.
168    pub guest_port: u16,
169    /// The VM's endpoint host, as `run` reported it.
170    pub endpoint: String,
171    /// How long one non-upgraded exchange may take.
172    pub exchange_timeout: std::time::Duration,
173}
174
175impl ForwardSpec {
176    /// A spec with the default exchange timeout.
177    pub fn new(bind: SocketAddr, guest_port: u16, endpoint: impl Into<String>) -> Self {
178        Self {
179            bind,
180            guest_port,
181            endpoint: endpoint.into(),
182            exchange_timeout: DEFAULT_EXCHANGE_TIMEOUT,
183        }
184    }
185}
186
187/// The absolute URL a proxied request targets, given the endpoint and the request target.
188///
189/// A bare host is read as `https`, for the reason [`crate::session::http::ReqwestBackend`]
190/// gives: the platform hands back a hostname, and defaulting to plain HTTP on the strength
191/// of a missing prefix would put a bearer token on the wire in clear text.
192pub fn upstream_url(endpoint: &str, target: &str) -> String {
193    let base = endpoint.trim_end_matches('/');
194    let base = if base.starts_with("https://") || base.starts_with("http://") {
195        base.to_string()
196    } else {
197        format!("https://{base}")
198    };
199    if target.starts_with('/') {
200        format!("{base}{target}")
201    } else {
202        format!("{base}/{target}")
203    }
204}
205
206/// The URL a direct session's request targets: the endpoint's host at `guest_port`.
207///
208/// Through the proxy the guest port travels as a header; with no proxy in the path, the port
209/// is the port. The scheme is the endpoint's, read the way [`upstream_url`] reads it.
210pub fn direct_url(endpoint: &str, guest_port: u16, target: &str) -> Result<String, Error> {
211    let mut url = reqwest::Url::parse(&upstream_url(endpoint, target)).map_err(|err| {
212        Error::new(
213            ErrorKind::InvalidArg,
214            format!("{endpoint} is not an endpoint a request can be addressed to: {err}"),
215        )
216    })?;
217    url.set_port(Some(guest_port)).map_err(|()| {
218        Error::new(
219            ErrorKind::InvalidArg,
220            format!("{endpoint} names no host a port can be set on"),
221        )
222    })?;
223    Ok(url.to_string())
224}
225
226/// Header names this forwarder must never copy from the caller's request.
227///
228/// The two proxy headers are minted per hop, so a client that sent its own would either be
229/// overridden silently or — worse — have its value forwarded and the mint skipped. Refusing
230/// to copy them means the minted pair is the only pair, which is what makes the mint
231/// schedule authoritative. `host` is dropped because the upstream host is the endpoint's,
232/// not the local listener's.
233pub const STRIPPED_REQUEST_HEADERS: [&str; 3] = [PROXY_AUTH_HEADER, PROXY_PORT_HEADER, "host"];
234
235/// Whether a header from the local client is forwarded upstream.
236pub fn forwards_request_header(name: &str) -> bool {
237    !STRIPPED_REQUEST_HEADERS
238        .iter()
239        .any(|stripped| stripped.eq_ignore_ascii_case(name))
240}
241
242/// The HTTP client a forwarder's hops go through.
243///
244/// A newtype rather than a re-exported `reqwest::Client`, because the CLI is forbidden from
245/// naming `reqwest` at all (`crates/microvms-cli/tests/thinness.rs`) — so a caller there cannot build
246/// one, and handing back the foreign type would only move the compile error. Pooled for the
247/// whole tunnel: a page-load of thirty assets should not pay thirty TLS handshakes.
248pub struct ForwardClient {
249    inner: reqwest::Client,
250}
251
252impl ForwardClient {
253    /// A pooled client for one tunnel.
254    pub fn new() -> Result<Self, Error> {
255        reqwest::Client::builder()
256            .build()
257            .map(|inner| Self { inner })
258            .map_err(|err| {
259                Error::new(
260                    ErrorKind::Unexpected,
261                    format!("could not build the forwarder's HTTP client: {err}"),
262                )
263            })
264    }
265}
266
267impl std::fmt::Debug for ForwardClient {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        f.debug_struct("ForwardClient").finish_non_exhaustive()
270    }
271}
272
273/// Binds the listener and reports the address, without serving anything yet.
274///
275/// Split from serving so a caller can print "listening on 8080" before the first
276/// connection, and so a bind failure — the common one, an address already in use — is a
277/// distinct error from a forwarding failure.
278pub async fn bind(spec: &ForwardSpec) -> Result<TcpListener, Error> {
279    TcpListener::bind(spec.bind).await.map_err(|err| {
280        Error::new(
281            ErrorKind::InvalidArg,
282            format!(
283                "could not bind {}: {err}. Another process is probably already listening \
284                 there; pass a different local port.",
285                spec.bind
286            ),
287        )
288    })
289}
290
291/// The minted proxy headers for this spec's guest port, plus the `host` the endpoint wants.
292///
293/// One place, called per exchange. See the module docs on why this is not hoisted out of the
294/// request path.
295pub async fn hop_headers(
296    auth: &Arc<ProxyAuth>,
297    guest_port: u16,
298) -> Result<Vec<(String, String)>, Error> {
299    auth.headers_for_port(guest_port).await
300}
301
302/// Accepts one connection, for a caller driving an accept loop of its own.
303///
304/// The CLI's and the SDKs' loop is [`super::serve::serve_forward`], which takes the stop, the
305/// limit and a callback as parameters; this is for a caller that wants the primitive.
306pub async fn accept(listener: &TcpListener) -> Result<(TcpStream, SocketAddr), Error> {
307    listener.accept().await.map_err(|err| {
308        Error::new(
309            ErrorKind::Unexpected,
310            format!("the local listener stopped accepting: {err}"),
311        )
312    })
313}
314
315/// Whether this request/response pair is a protocol upgrade the forwarder must splice.
316///
317/// Reads the **response** status rather than the request's `Upgrade` header, deliberately: a
318/// client may offer an upgrade the guest declines, and in that case the exchange is ordinary
319/// HTTP with an ordinary body. 101 is the only status that means "neither end speaks HTTP
320/// from here", which is the only condition under which splicing bytes is correct.
321pub fn is_upgrade(status: u16) -> bool {
322    status == 101
323}
324
325/// One end of the local connection, as a duplex stream.
326///
327/// A trait alias in all but name, so the serving functions below take either a real
328/// [`TcpStream`] or the in-memory duplex a test uses. Without it every test of the serving
329/// path would need a real socket, and the failure modes worth testing — a refusal, an
330/// upgrade, a mint inside the request path — have nothing to do with sockets.
331pub trait LocalStream: AsyncRead + AsyncWrite + Unpin + Send {}
332impl<T: AsyncRead + AsyncWrite + Unpin + Send> LocalStream for T {}
333
334/// Serves one local connection: reads its request, forwards it, and either relays the
335/// response or splices an upgraded stream.
336///
337/// # The request is parsed, not tunnelled
338///
339/// A local client speaks cleartext HTTP to this listener while the upstream hop is HTTPS to
340/// the endpoint, so the two cannot be a byte pipe: the request has to be understood well
341/// enough to be re-issued with the minted headers, and the response well enough to know
342/// whether 101 arrived. Once it has, parsing stops — see the module docs.
343///
344/// # A refusal is answered, not dropped
345///
346/// A 403 or 502 from the proxy becomes a plain-text HTTP response carrying
347/// [`refusal_explanation`]'s sentence, so a browser renders the reason. Dropping the socket
348/// would render as "the site can't be reached", which names the wrong component.
349pub async fn serve_connection<S, F>(
350    local: S,
351    spec: &ForwardSpec,
352    auth: &Arc<ProxyAuth>,
353    client: &ForwardClient,
354    on_event: F,
355) -> Result<(), Error>
356where
357    S: LocalStream,
358    F: FnMut(ForwardEvent),
359{
360    serve_connection_via(local, spec, Some(auth), client, on_event).await
361}
362
363/// [`serve_connection`] over an optional proxy credential.
364///
365/// `None` is a direct session's, which is already on the far side of the endpoint proxy: no
366/// proxy headers are minted, and the request goes to the endpoint's host at the guest port
367/// ([`direct_url`]), since there is no proxy to route it there by header. The serving loop
368/// (`super::serve`) takes whichever the session has.
369pub(crate) async fn serve_connection_via<S, F>(
370    mut local: S,
371    spec: &ForwardSpec,
372    auth: Option<&Arc<ProxyAuth>>,
373    client: &ForwardClient,
374    mut on_event: F,
375) -> Result<(), Error>
376where
377    S: LocalStream,
378    F: FnMut(ForwardEvent),
379{
380    let head = match read_request_head(&mut local).await? {
381        Some(head) => head,
382        // A connection that closed before sending a request line. Browsers open and abandon
383        // speculative connections constantly, so this is normal traffic and not an error.
384        None => return Ok(()),
385    };
386
387    // Minted per exchange rather than once at bind time. This call is the token refresh —
388    // see the module docs.
389    let (minted, url) = match auth {
390        Some(auth) => (
391            hop_headers(auth, spec.guest_port).await?,
392            upstream_url(&spec.endpoint, &head.target),
393        ),
394        None => (
395            Vec::new(),
396            direct_url(&spec.endpoint, spec.guest_port, &head.target)?,
397        ),
398    };
399    let method = reqwest::Method::from_bytes(head.method.as_bytes()).map_err(|err| {
400        Error::new(
401            ErrorKind::InvalidArg,
402            format!(
403                "the local client sent an unusable method {:?}: {err}",
404                head.method
405            ),
406        )
407    })?;
408
409    let mut request = client
410        .inner
411        .request(method, &url)
412        .timeout(spec.exchange_timeout);
413    for (name, value) in head
414        .headers
415        .iter()
416        .filter(|(name, _)| forwards_request_header(name))
417    {
418        request = request.header(name, value);
419    }
420    for (name, value) in &minted {
421        request = request.header(name, value);
422    }
423    if !head.body.is_empty() {
424        request = request.body(head.body.clone());
425    }
426
427    let response = request.send().await.map_err(|err| {
428        Error::new(
429            ErrorKind::Unexpected,
430            format!(
431                "forwarding {} {} to the endpoint failed: {err}",
432                head.method, head.target
433            ),
434        )
435    })?;
436    let status = response.status().as_u16();
437
438    if let Some(explanation) = refusal_explanation(status, spec.guest_port) {
439        on_event(ForwardEvent::Refused {
440            status,
441            explanation: explanation.clone(),
442        });
443        write_local_error(&mut local, status, &explanation).await?;
444        return Ok(());
445    }
446
447    if is_upgrade(status) {
448        // Relay the 101 verbatim first: the client is waiting for the handshake response,
449        // including whatever subprotocol the guest negotiated, before it will speak the
450        // upgraded protocol.
451        write_response_head(&mut local, status, response.headers()).await?;
452        let mut upstream = response.upgrade().await.map_err(|err| {
453            Error::new(
454                ErrorKind::Unexpected,
455                format!("the endpoint answered 101 but the connection did not upgrade: {err}"),
456            )
457        })?;
458        on_event(ForwardEvent::Forwarded {
459            status,
460            upgraded: true,
461        });
462        splice(&mut local, &mut upstream).await?;
463        return Ok(());
464    }
465
466    let headers = response.headers().clone();
467    let body = response.bytes().await.map_err(|err| {
468        Error::new(
469            ErrorKind::Unexpected,
470            format!("reading the guest's response body failed: {err}"),
471        )
472    })?;
473    write_response(&mut local, status, &headers, &body).await?;
474    on_event(ForwardEvent::Forwarded {
475        status,
476        upgraded: false,
477    });
478    Ok(())
479}
480
481/// A request head read off a local connection.
482///
483/// The body is collected here rather than streamed because a `content-length` body is what a
484/// local client sends on this path — a `POST` to a dev server — and streaming it would mean
485/// holding a half-read request across the mint. Chunked request bodies are not supported; a
486/// caller sending one gets a named refusal rather than a truncated forward.
487#[derive(Clone, Debug)]
488struct RequestHead {
489    method: String,
490    target: String,
491    headers: Vec<(String, String)>,
492    body: Vec<u8>,
493}
494
495/// Reads one HTTP request head (and any `content-length` body) from a local stream.
496///
497/// `Ok(None)` for a connection that closed before a request line arrived, which browsers do
498/// constantly with speculative connections.
499async fn read_request_head<S>(local: &mut S) -> Result<Option<RequestHead>, Error>
500where
501    S: AsyncRead + Unpin + ?Sized,
502{
503    use tokio::io::AsyncReadExt as _;
504
505    let mut buffer = Vec::new();
506    let mut byte = [0_u8; 1];
507    // Byte at a time until the header terminator. Slow in principle and irrelevant in
508    // practice — a request head is a few hundred bytes — and it means never over-reading
509    // into a body whose length the headers have not been parsed to discover yet.
510    loop {
511        match local.read(&mut byte).await {
512            Ok(0) if buffer.is_empty() => return Ok(None),
513            Ok(0) => {
514                return Err(Error::new(
515                    ErrorKind::Protocol,
516                    "the local client closed the connection mid-request".to_string(),
517                ));
518            }
519            Ok(_) => {
520                buffer.push(byte[0]);
521                if buffer.len() > MAX_REQUEST_HEAD_BYTES {
522                    return Err(Error::new(
523                        ErrorKind::InvalidArg,
524                        format!(
525                            "the local client's request head exceeded {MAX_REQUEST_HEAD_BYTES} \
526                             bytes without terminating"
527                        ),
528                    ));
529                }
530                if buffer.ends_with(b"\r\n\r\n") {
531                    break;
532                }
533            }
534            Err(err) => {
535                return Err(Error::new(
536                    ErrorKind::Unexpected,
537                    format!("reading the local client's request failed: {err}"),
538                ));
539            }
540        }
541    }
542
543    let text = String::from_utf8(buffer).map_err(|err| {
544        Error::new(
545            ErrorKind::Protocol,
546            format!("the local client's request head is not utf-8: {err}"),
547        )
548    })?;
549    let mut lines = text.split("\r\n");
550    let request_line = lines.next().unwrap_or_default();
551    let mut parts = request_line.split_whitespace();
552    let method = parts.next().unwrap_or_default().to_string();
553    let target = parts.next().unwrap_or_default().to_string();
554    if method.is_empty() || target.is_empty() {
555        return Err(Error::new(
556            ErrorKind::Protocol,
557            format!("the local client sent an unparseable request line {request_line:?}"),
558        ));
559    }
560
561    let mut headers = Vec::new();
562    for line in lines {
563        if line.is_empty() {
564            continue;
565        }
566        let Some((name, value)) = line.split_once(':') else {
567            return Err(Error::new(
568                ErrorKind::Protocol,
569                format!("the local client sent an unparseable header line {line:?}"),
570            ));
571        };
572        headers.push((name.trim().to_string(), value.trim().to_string()));
573    }
574
575    if headers.iter().any(|(name, value)| {
576        name.eq_ignore_ascii_case("transfer-encoding") && value.contains("chunked")
577    }) {
578        return Err(Error::new(
579            ErrorKind::InvalidArg,
580            "the local client sent a chunked request body, which this forwarder does not \
581             re-frame. Send a content-length body, or report the client that needs chunked \
582             uploads so the forwarder can grow a streaming path."
583                .to_string(),
584        ));
585    }
586
587    let length: usize = headers
588        .iter()
589        .find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
590        .and_then(|(_, value)| value.parse().ok())
591        .unwrap_or(0);
592
593    let mut body = vec![0_u8; length];
594    if length > 0 {
595        local.read_exact(&mut body).await.map_err(|err| {
596            Error::new(
597                ErrorKind::Protocol,
598                format!("the local client announced {length} body bytes and sent fewer: {err}"),
599            )
600        })?;
601    }
602
603    Ok(Some(RequestHead {
604        method,
605        target,
606        headers,
607        body,
608    }))
609}
610
611/// The cap on a request head, so a client that never sends the terminator cannot grow the
612/// buffer without bound. Generous: cookies and long URLs are ordinary.
613const MAX_REQUEST_HEAD_BYTES: usize = 64 * 1024;
614
615/// Headers a response must not carry back to the local client verbatim.
616///
617/// `content-length` and `transfer-encoding` are re-derived from the body actually written, so
618/// copying the upstream values would risk announcing a length that disagrees with the bytes
619/// — the failure that makes a browser hang waiting for a body that already ended.
620const RECOMPUTED_RESPONSE_HEADERS: [&str; 2] = ["content-length", "transfer-encoding"];
621
622/// Writes a status line and headers, without a body. Used for the 101 relay.
623async fn write_response_head<W>(
624    local: &mut W,
625    status: u16,
626    headers: &reqwest::header::HeaderMap,
627) -> Result<(), Error>
628where
629    W: AsyncWrite + Unpin + ?Sized,
630{
631    let mut head = format!("HTTP/1.1 {status} {}\r\n", reason_phrase(status));
632    for (name, value) in headers {
633        if let Ok(value) = value.to_str() {
634            head.push_str(&format!("{}: {}\r\n", name.as_str(), value));
635        }
636    }
637    head.push_str("\r\n");
638    local.write_all(head.as_bytes()).await.map_err(|err| {
639        Error::new(
640            ErrorKind::Unexpected,
641            format!("could not write the response head to the local client: {err}"),
642        )
643    })?;
644    local.flush().await.map_err(|err| {
645        Error::new(
646            ErrorKind::Unexpected,
647            format!("could not flush the response head: {err}"),
648        )
649    })
650}
651
652/// Writes a full response, re-deriving the framing headers from `body`.
653async fn write_response<W>(
654    local: &mut W,
655    status: u16,
656    headers: &reqwest::header::HeaderMap,
657    body: &[u8],
658) -> Result<(), Error>
659where
660    W: AsyncWrite + Unpin + ?Sized,
661{
662    let mut head = format!("HTTP/1.1 {status} {}\r\n", reason_phrase(status));
663    for (name, value) in headers {
664        if RECOMPUTED_RESPONSE_HEADERS
665            .iter()
666            .any(|skip| skip.eq_ignore_ascii_case(name.as_str()))
667        {
668            continue;
669        }
670        if let Ok(value) = value.to_str() {
671            head.push_str(&format!("{}: {}\r\n", name.as_str(), value));
672        }
673    }
674    head.push_str(&format!("content-length: {}\r\n\r\n", body.len()));
675
676    local.write_all(head.as_bytes()).await.map_err(|err| {
677        Error::new(
678            ErrorKind::Unexpected,
679            format!("could not write the response to the local client: {err}"),
680        )
681    })?;
682    local.write_all(body).await.map_err(|err| {
683        Error::new(
684            ErrorKind::Unexpected,
685            format!("could not write the response body to the local client: {err}"),
686        )
687    })?;
688    local.flush().await.map_err(|err| {
689        Error::new(
690            ErrorKind::Unexpected,
691            format!("could not flush the response: {err}"),
692        )
693    })
694}
695
696/// A reason phrase for the statuses this forwarder writes itself.
697///
698/// Only the ones it originates need to be right; everything else is relayed from upstream
699/// where the phrase is cosmetic (HTTP/1.1 clients read the code, not the phrase).
700fn reason_phrase(status: u16) -> &'static str {
701    match status {
702        101 => "Switching Protocols",
703        200 => "OK",
704        403 => "Forbidden",
705        404 => "Not Found",
706        500 => "Internal Server Error",
707        502 => "Bad Gateway",
708        504 => "Gateway Timeout",
709        _ => "Status",
710    }
711}
712
713/// Writes an HTTP error response to a local client whose exchange could not be proxied.
714///
715/// Plain text and a real status rather than dropping the socket: a browser pointed at a
716/// tunnel whose token lost its scope should render the reason, and a dropped connection
717/// renders as "the site can't be reached", which names the wrong component.
718pub async fn write_local_error<W>(stream: &mut W, status: u16, body: &str) -> Result<(), Error>
719where
720    W: AsyncWrite + Unpin + ?Sized,
721{
722    let reason = match status {
723        403 => "Forbidden",
724        502 => "Bad Gateway",
725        504 => "Gateway Timeout",
726        _ => "Bad Gateway",
727    };
728    let response = format!(
729        "HTTP/1.1 {status} {reason}\r\ncontent-type: text/plain; charset=utf-8\r\n\
730         content-length: {}\r\nconnection: close\r\n\r\n{body}",
731        body.len()
732    );
733    stream.write_all(response.as_bytes()).await.map_err(|err| {
734        Error::new(
735            ErrorKind::Unexpected,
736            format!("could not answer the local client: {err}"),
737        )
738    })?;
739    stream.flush().await.map_err(|err| {
740        Error::new(
741            ErrorKind::Unexpected,
742            format!("could not flush the local client's response: {err}"),
743        )
744    })
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use microvms_app::session::proxy::DEFAULT_AGENT_PORT;
751    use microvms_app::testing::{CountingMinter, TestClock};
752
753    /// A [`ProxyAuth`] over the shared counting minter, so a test can read the mint count and
754    /// the scopes that were asked for.
755    fn auth_pair() -> (Arc<ProxyAuth>, Arc<CountingMinter>) {
756        let minter = Arc::new(CountingMinter::default());
757        let auth = ProxyAuth::with_refresh_after(
758            minter.clone(),
759            DEFAULT_AGENT_PORT,
760            std::time::Duration::from_secs(30 * 60),
761            Arc::new(TestClock::default()),
762        )
763        .expect("the default refresh interval is below the ceiling");
764        (Arc::new(auth), minter)
765    }
766
767    /// A one-shot upstream HTTP/1.1 server on loopback.
768    ///
769    /// Real sockets rather than a mocked reqwest, because what these tests check is that the
770    /// bytes reqwest puts on the wire carry the minted headers — and a fake client would be
771    /// asserting against the fake. Returns the bound address and a handle yielding the request
772    /// text the server saw.
773    async fn upstream(response: &'static str) -> (String, tokio::task::JoinHandle<String>) {
774        let listener = TcpListener::bind("127.0.0.1:0").await.expect("a free port");
775        let addr = listener.local_addr().expect("bound");
776        let handle = tokio::spawn(async move {
777            // Bounded, so a forwarder that never reaches the upstream fails the test rather
778            // than hanging it.
779            let (mut socket, _) =
780                tokio::time::timeout(std::time::Duration::from_secs(10), listener.accept())
781                    .await
782                    .expect("the forwarder reaches the upstream")
783                    .expect("one connection");
784            let head = read_request_head(&mut socket)
785                .await
786                .expect("a readable request")
787                .expect("a request arrived");
788            socket
789                .write_all(response.as_bytes())
790                .await
791                .expect("the canned response is written");
792            socket.flush().await.expect("flushed");
793            let headers = head
794                .headers
795                .iter()
796                .map(|(name, value)| format!("{}: {value}", name.to_ascii_lowercase()))
797                .collect::<Vec<_>>()
798                .join("\n");
799            format!("{} {}\n{headers}", head.method, head.target)
800        });
801        (format!("http://{addr}"), handle)
802    }
803
804    /// Drives one exchange through [`serve_connection`] and returns what the local client saw
805    /// plus every event the forwarder reported.
806    async fn exchange(
807        request: &str,
808        endpoint: String,
809        guest_port: u16,
810        auth: &Arc<ProxyAuth>,
811    ) -> (String, Vec<ForwardEvent>) {
812        let (mut client, server) = tokio::io::duplex(64 * 1024);
813        let spec = ForwardSpec::new(
814            "127.0.0.1:0".parse().expect("an addr"),
815            guest_port,
816            endpoint,
817        );
818        let http = ForwardClient::new().expect("a client");
819
820        let auth = auth.clone();
821        let served = tokio::spawn(async move {
822            let mut events = Vec::new();
823            let result =
824                serve_connection(server, &spec, &auth, &http, |event| events.push(event)).await;
825            (result, events)
826        });
827
828        client
829            .write_all(request.as_bytes())
830            .await
831            .expect("the request is written");
832        client.flush().await.expect("flushed");
833
834        let (result, events) = served.await.expect("the task joins");
835        result.expect("the exchange completes");
836
837        let mut seen = Vec::new();
838        tokio::io::AsyncReadExt::read_to_end(&mut client, &mut seen)
839            .await
840            .expect("the client reads the response");
841        (String::from_utf8_lossy(&seen).to_string(), events)
842    }
843
844    #[tokio::test]
845    async fn an_ordinary_get_reaches_the_guest_carrying_both_minted_headers() {
846        // The whole point of layer 1: a local client's request arrives upstream with the
847        // port-scoped credential attached, and the guest's response comes back intact.
848        let (endpoint, upstream_saw) =
849            upstream("HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nhello").await;
850        let (auth, minter) = auth_pair();
851
852        let (seen, events) = exchange(
853            "GET /index.html HTTP/1.1\r\nhost: localhost:8080\r\nuser-agent: probe\r\n\r\n",
854            endpoint,
855            8080,
856            &auth,
857        )
858        .await;
859
860        let request = upstream_saw.await.expect("the upstream task joins");
861        assert!(request.starts_with("GET /index.html"), "{request}");
862        assert!(
863            request.contains(&format!(
864                "{}: jwe-0",
865                PROXY_AUTH_HEADER.to_ascii_lowercase()
866            )),
867            "the minted auth header did not reach upstream: {request}"
868        );
869        assert!(
870            request.contains(&format!("{}: 8080", PROXY_PORT_HEADER.to_ascii_lowercase())),
871            "the port header did not name the guest port: {request}"
872        );
873        assert!(request.contains("user-agent: probe"), "{request}");
874
875        assert!(seen.starts_with("HTTP/1.1 200 OK\r\n"), "{seen}");
876        assert!(seen.ends_with("hello"), "{seen}");
877        assert!(
878            matches!(
879                events.as_slice(),
880                [ForwardEvent::Forwarded {
881                    status: 200,
882                    upgraded: false
883                }]
884            ),
885            "{events:?}"
886        );
887
888        // The scope asked for is the guest port, not the agent port alone — the 2026-08-15
889        // defect. `mint_for_ports` records what was requested.
890        let requested = minter.requested();
891        assert!(
892            requested.iter().any(|ports| ports.contains(&8080)),
893            "the mint never asked for port 8080: {requested:?}"
894        );
895    }
896
897    /// **Falsification**: drop the `forwards_request_header` filter in `serve_connection` and
898    /// the forged value arrives beside the minted one, which is a request whose credential
899    /// the caller chose.
900    #[tokio::test]
901    async fn the_caller_cannot_smuggle_its_own_proxy_headers_upstream() {
902        let (endpoint, upstream_saw) =
903            upstream("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok").await;
904        let (auth, _minter) = auth_pair();
905
906        let request = format!(
907            "GET / HTTP/1.1\r\nhost: local\r\n{PROXY_AUTH_HEADER}: forged-jwe\r\n\
908             {PROXY_PORT_HEADER}: 1\r\n\r\n"
909        );
910        let (_seen, _events) = exchange(&request, endpoint, 8080, &auth).await;
911
912        let saw = upstream_saw.await.expect("the upstream task joins");
913        assert!(
914            !saw.contains("forged-jwe"),
915            "a caller's forged credential was forwarded: {saw}"
916        );
917        assert!(
918            saw.contains(&format!("{}: 8080", PROXY_PORT_HEADER.to_ascii_lowercase())),
919            "the minted port header should be the only one: {saw}"
920        );
921        assert!(
922            !saw.contains("host: local"),
923            "the local host header must not override the endpoint's: {saw}"
924        );
925    }
926
927    #[tokio::test]
928    async fn a_scope_refusal_is_answered_with_the_sentence_that_names_it() {
929        // 403 is the one status that means "the credential never authorized this port", and a
930        // browser must render that rather than "the site can't be reached".
931        let (endpoint, upstream_saw) =
932            upstream("HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\n\r\n").await;
933        let (auth, _minter) = auth_pair();
934
935        let (seen, events) = exchange(
936            "GET / HTTP/1.1\r\nhost: local\r\n\r\n",
937            endpoint,
938            8080,
939            &auth,
940        )
941        .await;
942        let _ = upstream_saw.await;
943
944        assert!(seen.starts_with("HTTP/1.1 403 Forbidden\r\n"), "{seen}");
945        assert!(seen.contains("scope does not cover"), "{seen}");
946        match events.as_slice() {
947            [
948                ForwardEvent::Refused {
949                    status: 403,
950                    explanation,
951                },
952            ] => {
953                assert!(explanation.contains("8080"), "{explanation}");
954            }
955            other => panic!("expected one 403 refusal, got {other:?}"),
956        }
957    }
958
959    #[tokio::test]
960    async fn a_dead_guest_server_is_reported_as_502_rather_than_as_an_auth_problem() {
961        let (endpoint, upstream_saw) =
962            upstream("HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\n\r\n").await;
963        let (auth, _minter) = auth_pair();
964
965        let (seen, events) = exchange(
966            "GET / HTTP/1.1\r\nhost: local\r\n\r\n",
967            endpoint,
968            5432,
969            &auth,
970        )
971        .await;
972        let _ = upstream_saw.await;
973
974        assert!(seen.contains("nothing listening on port 5432"), "{seen}");
975        assert!(
976            matches!(
977                events.as_slice(),
978                [ForwardEvent::Refused { status: 502, .. }]
979            ),
980            "{events:?}"
981        );
982    }
983
984    #[tokio::test]
985    async fn the_guests_own_404_is_relayed_and_not_reinterpreted() {
986        // A 404 from the dev server is the dev server's answer. A forwarder that explained it
987        // would be inventing a diagnosis for someone else's status.
988        let (endpoint, upstream_saw) =
989            upstream("HTTP/1.1 404 Not Found\r\ncontent-length: 9\r\n\r\nno-route!").await;
990        let (auth, _minter) = auth_pair();
991
992        let (seen, events) = exchange(
993            "GET /nope HTTP/1.1\r\nhost: local\r\n\r\n",
994            endpoint,
995            8080,
996            &auth,
997        )
998        .await;
999        let _ = upstream_saw.await;
1000
1001        assert!(seen.starts_with("HTTP/1.1 404 Not Found\r\n"), "{seen}");
1002        assert!(seen.ends_with("no-route!"), "{seen}");
1003        assert!(
1004            matches!(
1005                events.as_slice(),
1006                [ForwardEvent::Forwarded { status: 404, .. }]
1007            ),
1008            "{events:?}"
1009        );
1010    }
1011
1012    #[tokio::test]
1013    async fn a_post_body_is_forwarded_and_the_response_length_is_re_derived() {
1014        // The upstream announces a content-length; the forwarder must write one that matches
1015        // the bytes it actually relays, or a browser hangs waiting for a body that ended.
1016        let (endpoint, upstream_saw) = upstream(
1017            "HTTP/1.1 200 OK\r\ncontent-length: 3\r\ntransfer-encoding: identity\r\n\r\nack",
1018        )
1019        .await;
1020        let (auth, _minter) = auth_pair();
1021
1022        let (seen, _events) = exchange(
1023            "POST /submit HTTP/1.1\r\nhost: local\r\ncontent-length: 7\r\n\r\npayload",
1024            endpoint,
1025            8080,
1026            &auth,
1027        )
1028        .await;
1029        let saw = upstream_saw.await.expect("the upstream task joins");
1030
1031        assert!(saw.starts_with("POST /submit"), "{saw}");
1032        assert!(saw.contains("content-length: 7"), "{saw}");
1033        assert_eq!(
1034            seen.matches("content-length:").count(),
1035            1,
1036            "exactly one framing header should be written: {seen}"
1037        );
1038        assert!(seen.contains("content-length: 3\r\n"), "{seen}");
1039        assert!(
1040            !seen.to_ascii_lowercase().contains("transfer-encoding"),
1041            "the upstream framing header must not be copied: {seen}"
1042        );
1043        assert!(seen.ends_with("ack"), "{seen}");
1044    }
1045
1046    #[tokio::test]
1047    async fn a_101_relays_the_handshake_and_then_stops_parsing() {
1048        // The WebSocket path. The 101 and its negotiated subprotocol reach the client
1049        // verbatim, and everything after it is bytes in both directions — this module frames
1050        // nothing, so it cannot frame anything wrongly.
1051        let listener = TcpListener::bind("127.0.0.1:0").await.expect("a free port");
1052        let addr = listener.local_addr().expect("bound");
1053        let guest = tokio::spawn(async move {
1054            let (mut socket, _) = listener.accept().await.expect("one connection");
1055            let _ = read_request_head(&mut socket)
1056                .await
1057                .expect("a request")
1058                .expect("present");
1059            socket
1060                .write_all(
1061                    b"HTTP/1.1 101 Switching Protocols\r\nupgrade: websocket\r\n\
1062                      connection: upgrade\r\nsec-websocket-protocol: my-app-protocol\r\n\r\n",
1063                )
1064                .await
1065                .expect("the handshake is written");
1066            socket.flush().await.expect("flushed");
1067            // Now speak the upgraded protocol: read the client's frame, answer it.
1068            let mut frame = [0_u8; 5];
1069            tokio::io::AsyncReadExt::read_exact(&mut socket, &mut frame)
1070                .await
1071                .expect("the client's bytes arrive");
1072            socket
1073                .write_all(b"PONG")
1074                .await
1075                .expect("the answer is written");
1076            socket.flush().await.expect("flushed");
1077            frame.to_vec()
1078        });
1079
1080        let (auth, _minter) = auth_pair();
1081        let (mut client, server) = tokio::io::duplex(64 * 1024);
1082        let spec = ForwardSpec::new(
1083            "127.0.0.1:0".parse().expect("an addr"),
1084            8080,
1085            format!("http://{addr}"),
1086        );
1087        let http = ForwardClient::new().expect("a client");
1088        let served = tokio::spawn(async move {
1089            let mut events = Vec::new();
1090            let result =
1091                serve_connection(server, &spec, &auth, &http, |event| events.push(event)).await;
1092            (result, events)
1093        });
1094
1095        client
1096            .write_all(
1097                b"GET /socket HTTP/1.1\r\nhost: local\r\nupgrade: websocket\r\n\
1098                  connection: upgrade\r\nsec-websocket-protocol: my-app-protocol\r\n\r\n",
1099            )
1100            .await
1101            .expect("the handshake request is written");
1102        client.flush().await.expect("flushed");
1103
1104        // The handshake response arrives before any upgraded byte, which is what lets a
1105        // client know the negotiation succeeded.
1106        let mut head = Vec::new();
1107        let mut byte = [0_u8; 1];
1108        while !head.ends_with(b"\r\n\r\n") {
1109            tokio::io::AsyncReadExt::read_exact(&mut client, &mut byte)
1110                .await
1111                .expect("the handshake response arrives");
1112            head.push(byte[0]);
1113        }
1114        let head = String::from_utf8_lossy(&head).to_string();
1115        assert!(
1116            head.starts_with("HTTP/1.1 101 Switching Protocols\r\n"),
1117            "{head}"
1118        );
1119        assert!(
1120            head.to_ascii_lowercase()
1121                .contains("sec-websocket-protocol: my-app-protocol"),
1122            "the guest's negotiated subprotocol must reach the client: {head}"
1123        );
1124
1125        client
1126            .write_all(b"PING!")
1127            .await
1128            .expect("a frame is written");
1129        client.flush().await.expect("flushed");
1130        let mut answer = [0_u8; 4];
1131        tokio::io::AsyncReadExt::read_exact(&mut client, &mut answer)
1132            .await
1133            .expect("the guest's answer arrives");
1134        assert_eq!(&answer, b"PONG");
1135
1136        drop(client);
1137        let guest_saw = guest.await.expect("the guest task joins");
1138        assert_eq!(
1139            &guest_saw, b"PING!",
1140            "the client's bytes reached the guest unframed"
1141        );
1142
1143        let (result, events) = served.await.expect("the task joins");
1144        result.expect("the splice ends cleanly");
1145        assert!(
1146            events.iter().any(|event| matches!(
1147                event,
1148                ForwardEvent::Forwarded {
1149                    status: 101,
1150                    upgraded: true
1151                }
1152            )),
1153            "{events:?}"
1154        );
1155    }
1156
1157    #[tokio::test]
1158    async fn every_exchange_mints_through_the_cache_rather_than_capturing_headers_once() {
1159        // TRAP-9's shape for a tunnel: the credential is re-read per hop, so crossing the
1160        // sixty-minute ceiling is the cache's job and not a timer here. Two exchanges over one
1161        // ProxyAuth must both go through it.
1162        let (auth, minter) = auth_pair();
1163
1164        for _ in 0..2 {
1165            let (endpoint, upstream_saw) =
1166                upstream("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok").await;
1167            let (_seen, _events) = exchange(
1168                "GET / HTTP/1.1\r\nhost: local\r\n\r\n",
1169                endpoint,
1170                8080,
1171                &auth,
1172            )
1173            .await;
1174            let saw = upstream_saw.await.expect("the upstream task joins");
1175            assert!(
1176                saw.contains(&PROXY_AUTH_HEADER.to_ascii_lowercase()),
1177                "every hop carries the minted credential: {saw}"
1178            );
1179        }
1180
1181        // One mint, two hops: the second read the cache. That is the correct behaviour — the
1182        // assertion that matters is that neither hop went out uncredentialed, and that the
1183        // count is the observable saying whether a refresh happened.
1184        assert_eq!(
1185            auth.mint_count(),
1186            1,
1187            "the second hop should reuse the cached token rather than re-mint"
1188        );
1189        assert_eq!(minter.requested().len(), 1);
1190    }
1191
1192    #[tokio::test]
1193    async fn a_chunked_request_body_is_refused_by_name_rather_than_truncated() {
1194        // This forwarder does not re-frame chunked bodies. Silently forwarding the head and
1195        // dropping the body would corrupt an upload in a way the user cannot see.
1196        let (mut client, mut server) = tokio::io::duplex(4096);
1197        client
1198            .write_all(b"POST /up HTTP/1.1\r\nhost: local\r\ntransfer-encoding: chunked\r\n\r\n")
1199            .await
1200            .expect("written");
1201        client.flush().await.expect("flushed");
1202
1203        let error = read_request_head(&mut server)
1204            .await
1205            .expect_err("chunked is refused");
1206        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1207        assert!(error.to_string().contains("chunked"), "{error}");
1208    }
1209
1210    #[tokio::test]
1211    async fn a_connection_that_closes_before_sending_anything_is_not_an_error() {
1212        // Browsers open speculative connections and abandon them. Treating that as a failure
1213        // would print an error per tab.
1214        let (client, mut server) = tokio::io::duplex(4096);
1215        drop(client);
1216        let head = read_request_head(&mut server)
1217            .await
1218            .expect("an empty connection is fine");
1219        assert!(head.is_none());
1220    }
1221
1222    #[tokio::test]
1223    async fn an_unterminated_request_head_is_capped_rather_than_buffered_without_bound() {
1224        let (mut client, mut server) = tokio::io::duplex(1024 * 1024);
1225        let writer = tokio::spawn(async move {
1226            // Never sends the terminator.
1227            let filler = vec![b'x'; MAX_REQUEST_HEAD_BYTES + 1024];
1228            let _ = client.write_all(b"GET / HTTP/1.1\r\nx: ").await;
1229            let _ = client.write_all(&filler).await;
1230            let _ = client.flush().await;
1231        });
1232
1233        let error = read_request_head(&mut server)
1234            .await
1235            .expect_err("the cap fires");
1236        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1237        assert!(error.to_string().contains("without terminating"), "{error}");
1238        writer.abort();
1239    }
1240
1241    #[test]
1242    fn only_a_101_counts_as_an_upgrade() {
1243        // A client may offer an upgrade the guest declines, and then the exchange is ordinary
1244        // HTTP with an ordinary body. Reading the request's Upgrade header instead would
1245        // splice a connection that still had a body to relay.
1246        assert!(is_upgrade(101));
1247        assert!(!is_upgrade(200));
1248        assert!(!is_upgrade(426));
1249    }
1250
1251    #[test]
1252    fn a_bare_endpoint_host_is_read_as_https() {
1253        // The reason is in the module docs: defaulting to http on a missing prefix puts a
1254        // bearer token on the wire in clear text.
1255        assert_eq!(
1256            upstream_url("vm-abc.example.aws", "/index.html"),
1257            "https://vm-abc.example.aws/index.html"
1258        );
1259    }
1260
1261    #[test]
1262    fn an_explicit_scheme_is_left_alone() {
1263        assert_eq!(
1264            upstream_url("https://vm-abc.example.aws/", "/a"),
1265            "https://vm-abc.example.aws/a"
1266        );
1267        assert_eq!(
1268            upstream_url("http://127.0.0.1:9000", "/a"),
1269            "http://127.0.0.1:9000/a"
1270        );
1271    }
1272
1273    #[test]
1274    fn a_target_without_a_leading_slash_still_forms_one_path() {
1275        assert_eq!(
1276            upstream_url("vm.example", "index.html"),
1277            "https://vm.example/index.html"
1278        );
1279    }
1280
1281    #[test]
1282    fn the_two_proxy_headers_are_never_copied_from_the_caller() {
1283        // A client that sent its own X-aws-proxy-auth must not have it forwarded: the
1284        // minted pair is the only pair, which is what keeps the mint schedule authoritative.
1285        assert!(!forwards_request_header("x-aws-proxy-auth"));
1286        assert!(!forwards_request_header("X-AWS-PROXY-AUTH"));
1287        assert!(!forwards_request_header("x-aws-proxy-port"));
1288        assert!(!forwards_request_header("Host"));
1289        assert!(forwards_request_header("user-agent"));
1290        assert!(forwards_request_header("sec-websocket-protocol"));
1291    }
1292
1293    #[test]
1294    fn the_refusal_wording_separates_a_scope_mistake_from_a_dead_server() {
1295        // The one diagnostic pair PLATFORM.md names, and the reason this function exists
1296        // rather than the CLI writing its own sentence.
1297        let scope = refusal_explanation(403, 8080).expect("403 explains itself");
1298        assert!(scope.contains("scope does not cover"), "{scope}");
1299        assert!(scope.contains("8080"), "{scope}");
1300
1301        let dead = refusal_explanation(502, 8080).expect("502 explains itself");
1302        assert!(dead.contains("nothing listening"), "{dead}");
1303        assert!(dead.contains("8080"), "{dead}");
1304
1305        assert!(
1306            refusal_explanation(401, 8080).is_some(),
1307            "401 is worth naming"
1308        );
1309        assert!(
1310            refusal_explanation(200, 8080).is_none(),
1311            "a success explains nothing"
1312        );
1313        assert!(
1314            refusal_explanation(404, 8080).is_none(),
1315            "the guest's own 404 is not ours"
1316        );
1317    }
1318
1319    #[tokio::test]
1320    async fn a_bind_collision_names_the_address_and_suggests_another_port() {
1321        let held = TcpListener::bind("127.0.0.1:0").await.expect("a free port");
1322        let addr = held.local_addr().expect("a bound address");
1323
1324        let spec = ForwardSpec::new(addr, 8080, "vm.example");
1325        let error = bind(&spec).await.expect_err("the address is taken");
1326        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1327        assert!(error.to_string().contains(&addr.to_string()), "{error}");
1328        assert!(
1329            error.to_string().contains("different local port"),
1330            "{error}"
1331        );
1332    }
1333
1334    #[tokio::test]
1335    async fn a_local_error_response_is_a_readable_http_message() {
1336        // A browser pointed at a scope-refused tunnel should render the reason; a dropped
1337        // socket renders as "the site can't be reached", which names the wrong component.
1338        let (mut client, mut server) = tokio::io::duplex(4096);
1339        let body = refusal_explanation(403, 8080).expect("wording");
1340        write_local_error(&mut server, 403, &body)
1341            .await
1342            .expect("the response is written");
1343        drop(server);
1344
1345        let mut got = Vec::new();
1346        tokio::io::AsyncReadExt::read_to_end(&mut client, &mut got)
1347            .await
1348            .expect("the client reads it");
1349        let text = String::from_utf8(got).expect("utf-8");
1350        assert!(text.starts_with("HTTP/1.1 403 Forbidden\r\n"), "{text}");
1351        assert!(text.contains("content-length: "), "{text}");
1352        assert!(text.ends_with(&body), "{text}");
1353    }
1354
1355    #[tokio::test]
1356    async fn splice_copies_both_directions_and_reports_the_byte_counts() {
1357        let (mut left_near, mut left_far) = tokio::io::duplex(4096);
1358        let (mut right_near, mut right_far) = tokio::io::duplex(4096);
1359
1360        let pump = tokio::spawn(async move { splice(&mut left_far, &mut right_near).await });
1361
1362        left_near.write_all(b"to-guest").await.expect("write");
1363        left_near.shutdown().await.expect("half close");
1364        right_far.write_all(b"from-guest!").await.expect("write");
1365        right_far.shutdown().await.expect("half close");
1366
1367        let mut to_guest = Vec::new();
1368        tokio::io::AsyncReadExt::read_to_end(&mut right_far, &mut to_guest)
1369            .await
1370            .expect("read");
1371        let mut from_guest = Vec::new();
1372        tokio::io::AsyncReadExt::read_to_end(&mut left_near, &mut from_guest)
1373            .await
1374            .expect("read");
1375
1376        let (a_to_b, b_to_a) = pump.await.expect("the task joins").expect("a clean splice");
1377        assert_eq!(to_guest, b"to-guest");
1378        assert_eq!(from_guest, b"from-guest!");
1379        assert_eq!(a_to_b, 8);
1380        assert_eq!(b_to_a, 11);
1381    }
1382}