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