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§
- Forward
Client - The HTTP client a forwarder’s hops go through.
- Forward
Spec - Where the forwarder listens and what it forwards to.
Enums§
- Forward
Event - 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§
- Local
Stream - 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
hostthe 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
Nonefor 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.