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