Skip to main content

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