Skip to main content

Module forward

Module forward 

Source
Expand description

A local TCP listener that forwards HTTP and WebSocket traffic to one guest port.

The client half of microvm port-forward. A caller binds a local address, and every connection accepted there is replayed against the VM’s endpoint with the two proxy headers ProxyAuth mints for the guest port. What arrives at the guest is an ordinary request from an ordinary client: the platform strips both headers before forwarding (measured 2026-08-15, docs/PLATFORM.md), so a server inside the VM needs no MicroVM awareness.

§Why this is in core rather than in the CLI

microvms-cli/tests/thinness.rs forbids hyper, http, and reqwest in the CLI’s dependency set, because each of them is a second path to AWS that does not go through this crate. A forwarder is an HTTP server, so it cannot live there. Putting it here also puts it behind the same ProxyAuth the session’s own requests use, which is the property TRAP-9 rests on: there is one mint schedule, and a long-lived tunnel opened at minute fifty gets a token minted at minute fifty.

§Every hop re-reads the header, and that is the refresh

ProxyAuth::headers_for_port is called per proxied request rather than once at bind time. The token’s sixty-minute ceiling is therefore crossed by consulting the cache that already knows about it — no timer here, no expiry arithmetic, and ProxyAuth::mint_count is the observable that says a refresh happened. A forwarder that captured the headers at startup would work for thirty minutes and then return 401s that read as a wrong agent token.

§An upgrade is forwarded as bytes, not as a protocol this module understands

A WebSocket handshake is an ordinary GET whose response is 101. After that, neither end speaks HTTP, so this module stops parsing and copies bytes in both directions until one side closes. This code frames nothing after the handshake, so it cannot frame anything wrongly, and an application subprotocol passes through untouched.

§The upgrade path does not reach a guest server through the endpoint proxy yet

Measured 2026-08-29, us-east-1, against a guest RFC 6455 echo server on port 8090. An upgrade re-issued here as an ordinary HTTPS GET — Upgrade: websocket and Connection: Upgrade forwarded as request headers — is answered by the proxy with 400 (an x-amzn-requestid on the response, and the guest logged no handshake at all, while the same handshake sent from inside the guest to 127.0.0.1:8090 answers 101).

The reason is in docs/PLATFORM.md: on the endpoint the WebSocket credential travels as Sec-WebSocket-Protocol values, not as the two proxy headers, because the browser WebSocket constructor cannot set a header. So a working tunnel has to open a real wss:// handshake offering crate::session::Session::connect_subprotocols’s three values, rather than replay the client’s GET over the HTTPS path. That is a genuine WebSocket client in this module, which the HTTP relay below is not.

What is measured as working on that path, from the same run: a wss:// handshake offering the three minted subprotocols reaches the guest, and binary frames survive byte-exact in both directions on a port-scoped token — including 0x00/0xFF payloads that a silent utf-8 round trip would corrupt, and a 300-byte frame that exercises the extended-length header. The guest observed opcode 2 and no sec-websocket-protocol header, so the proxy consumed all three values as documented. That is the transport premise the raw-TCP tunnel (issue #70 layer 2) rests on, and it is also what this module needs in order to carry an upgrade.

Until that client exists, is_upgrade and splice are exercised by the tests below against a local upstream, where the byte relay is correct — the untested edge is the handshake negotiation with the platform, not the splice.

§What a failure says

The proxy answers 403 for a token whose scope does not cover the port and 502 when the scope is right and nothing is listening in the guest. That pair is the only diagnostic separating a scope mistake from a dead server, and on the WebSocket path both collapse to close code 1006 with no reason. So this module surfaces the distinction from the HTTP status while it still has one, in ForwardEvent::Refused, rather than letting a caller rediscover it from a closed socket.

Structs§

ForwardClient
The HTTP client a forwarder’s hops go through.
ForwardSpec
Where the forwarder listens and what it forwards to.

Enums§

ForwardEvent
What the forwarder did, for a caller that wants to report progress.

Constants§

DEFAULT_EXCHANGE_TIMEOUT
How long one proxied exchange may take before it is abandoned.
STRIPPED_REQUEST_HEADERS
Header names this forwarder must never copy from the caller’s request.

Traits§

LocalStream
One end of the local connection, as a duplex stream.

Functions§

accept
Accepts one connection, for a caller driving the accept loop itself.
bind
Binds the listener and reports the address, without serving anything yet.
forwards_request_header
Whether a header from the local client is forwarded upstream.
hop_headers
The minted proxy headers for this spec’s guest port, plus the host the endpoint wants.
is_upgrade
Whether this request/response pair is a protocol upgrade the forwarder must splice.
refusal_explanation
The 403-vs-502 sentence, or None for a status that needs no explaining.
serve_connection
Serves one local connection: reads its request, forwards it, and either relays the response or splices an upgraded stream.
splice
Copies bytes both ways until one side closes, then returns.
upstream_url
The absolute URL a proxied request targets, given the endpoint and the request target.
write_local_error
Writes an HTTP error response to a local client whose exchange could not be proxied.