HTTP/2 Support
Laurel Proxy negotiates HTTP/2 with clients and with origins, independently, over the same MITM tunnel used for HTTP/1.1. There is no flag and no configuration -- ALPN decides each hop on its own, so an h2 client talking to an HTTP/1.1-only origin (or the reverse) is a normal case, not a special one, and the recording reflects it.
How It Works
- Client hop. The MITM TLS socket offers
ALPNProtocols: ['h2', 'http/1.1']. Whatever the client picks is what it gets. A client that sends no ALPN extension at all lands onhttp/1.1-- never guessed as h2. - Origin hop. Negotiated separately via its own ALPN probe, cached per
host:portso repeat requests to the same origin don't re-probe.
The two hops are independent by design: nothing about the client's protocol influences what's offered to the origin, or vice versa. The trade-off is a real one: an HTTPS origin costs one extra TLS handshake for ALPN discovery the first time it's seen, cached per host:port after that. It's invisible to clients, but it is a genuine network side effect worth knowing about if you're watching connection counts to an origin.
What Gets Recorded
Every captured request (and every WebSocket handshake row) carries two fields, separate from protocol (which is the URL scheme, http/https, not a wire protocol):
| Field | Meaning |
|---|---|
client_protocol | Wire protocol negotiated with the client: http/1.1 | h2 | null |
origin_protocol | Wire protocol negotiated with the origin: http/1.1 | h2 | null |
null means genuinely unknown -- never read it as http/1.1.Every exchange the proxy records sets both fields to a real value, so a nullis a legacy row from before this feature existed. Databases created before this feature get both columns backfilled to http/1.1 on first open after upgrading -- before HTTP/2 support existed, every recorded exchange genuinely spoke HTTP/1.1 on both hops, so that's a fact about the row, not a guess.
Finding the Mixed-Hops Case
Before this feature, an h2 exchange and an http/1.1 exchange recorded identically, because protocol is the URL scheme, not the wire protocol. Now the mixed case is queryable directly:
laurel-proxy requests --client-protocol h2 --origin-protocol http/1.1 --format agentCLI
laurel-proxy requests and laurel-proxy request <id> accept--client-protocol <protocol> and --origin-protocol <protocol>(either or both, exact match, http/1.1 or h2). An unrecognised value exits non-zero rather than silently returning everything, and both filters work with--tail.
# Find h2 exchanges
laurel-proxy requests --client-protocol h2 --format agent
# The mixed-hops case
laurel-proxy requests --client-protocol h2 --origin-protocol http/1.1 --format agent--format table tags an h2 client hop with a magenta H2 in the method column, the same "only the non-default is worth a badge" approach as the WebSocket WS marker -- the two never co-occur, since a WebSocket's client hop is always HTTP/1.1 by construction. laurel-proxy request <id> (table format) prints separate Client Hop and Origin Hop lines, showingunknown rather than a guess when unset. --format agent/--format jsoncarry client_protocol/origin_protocol on both commands and on--tail.
REST API
GET /api/requests accepts client_protocol andorigin_protocol query parameters (either or both), same exact-match semantics as the CLI. An unrecognised value returns 400.
curl "http://127.0.0.1:8081/api/requests?client_protocol=h2&origin_protocol=http%2F1.1"Web UI
The traffic list tags an h2 client hop with a small H2 badge in the method column, next to the request row. The request detail panel's metadata bar showsclient hop → origin hop (e.g. h2 → http/1.1), readingunknown for an unset value rather than guessing.
WebSocket Connections
A WebSocket handshake only ever arrives via the Upgrade header, which HTTP/2 forbids outright -- so every WebSocket connection Laurel Proxy records hasclient_protocol: 'http/1.1' and origin_protocol: 'http/1.1'by construction, not by default. There is no such thing as an h2 WebSocket connection in this proxy's recording, because there is no such connection to record.
Not Supported
- WebSockets over HTTP/2 (RFC 8441 Extended CONNECT). An h2 client attempting one fails cleanly rather than hanging -- a reset stream, a
405, or the client's own request builder refusing to send anUpgradeheader in the first place. None of the three is recorded as an exchange. - Cleartext h2c (prior-knowledge or
Upgrade-based). Rare in practice -- browsers don't do it -- and out of scope. - HTTP/3 / QUIC, and server push (deprecated, unimplemented in browsers).
Known Gaps
One class of truncated h2 response is genuinely indistinguishable from a clean end: a RST_STREAM(NO_ERROR) mid-body on a response with nocontent-length. Every observable property matches a clean end-of-stream, so this one shape can record as complete when it wasn't. Every other truncation shape -- a declared content-length that doesn't match, a non-NO_ERRORreset, a client-side cancel -- is caught and excluded from the recording.
Related
- CLI Reference -
--client-protocol/--origin-protocolfilters - REST API - Query parameters for the same filters
- Web UI - The H2 badge and hop metadata in the detail panel
- HTTPS Interception - How the MITM TLS socket is set up
- Architecture - Storage and migration for the new fields