roundtrip: support HTTP/2 fronting via ALPN-aware transport#9
Conversation
Frame the fronted request to match whichever protocol the edge selects via ALPN. Under a genuine browser ClientHello (Chrome 131) both CloudFront and Aliyun negotiate h2, which the HTTP/1.1-only transport could not parse (it choked on the h2 SETTINGS frame with a "malformed HTTP response"). doRequest now branches on the negotiated protocol: h2 goes through x/net/http2's single-shot NewClientConn (h2Body.Close tears down the conn and its reader goroutine), http/1.1 keeps the existing path. Routing is identical across both since the fronted host drives the Host header (h1) or the :authority pseudo-header (h2). Keeping the real Chrome ALPN (h2,http/1.1) avoids the fingerprint anomaly of advertising only http/1.1. Adds golang.org/x/net as a direct dep (already pinned v0.38.0 transitively via utls). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthrough
ChangesHTTP/2 ALPN routing
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Pull request overview
This PR makes the domain-fronting round trip ALPN-aware so that requests are framed using the protocol actually negotiated during the TLS handshake (HTTP/1.1 or HTTP/2), preventing failures when CDN edges select h2.
Changes:
- Branch request framing in
doRequestbased on negotiated ALPN and add an HTTP/2 code path usingx/net/http2. - Add an offline, pipe-backed HTTP/2 unit test validating
:authority, headers, path preservation, and HTTP/2 response. - Promote
golang.org/x/netto a direct dependency and document HTTP/2 support in the README.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| roundtrip.go | Adds ALPN detection and an HTTP/2 RoundTrip implementation over the already-established TLS conn. |
| h2_test.go | Introduces a pipe-backed HTTP/2 server test to validate correct authority/path/headers. |
| README.md | Documents HTTP/2 support and updates the “minimal dependencies” bullet. |
| go.mod | Promotes golang.org/x/net to a direct dependency. |
| go.sum | Adds checksums for newly direct/indirect module entries. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@h2_test.go`:
- Line 41: Replace the direct call to srv.Handshake() with a context-aware
approach using HandshakeContext for explicit timeout control. Create a context
with a reasonable timeout using context.WithTimeout, then pass that context to
srv.HandshakeContext() instead of calling Handshake() without context. This
ensures the handshake operation has an explicit deadline and can be properly
cancelled if needed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 69a65a30-e137-4884-97d6-1a18c6bb5f79
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (4)
README.mdgo.modh2_test.goroundtrip.go
… Connection token list - Centralize ALPN-aware framing in sendOverConn and route front vetting (verifyWithPost) through it too. Vetting gates whether a front becomes usable, and it ran unconditionally over HTTP/1.1 — so every h2 edge (CloudFront, Aliyun) would fail to vet and never enter the ready pool, defeating the h2 support. Adds TestVerifyWithPost_HTTP2. - h2Body.Close now returns the cc.Close() error when the body close itself succeeded, instead of discarding connection-teardown failures. - Detect "upgrade" as a token within the Connection header's comma-separated list (e.g. "keep-alive, Upgrade"), not just an exact match. Adds TestHasConnectionUpgrade. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make h2Body.cc an io.Closer (a *http2.ClientConn in practice) so the teardown precedence is unit-testable, and add TestH2Body_Close covering: the cc error surfaces when body close succeeds, a body-close error takes precedence, and the connection is closed exactly once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Alibaba Cloud / Aliyun CDN edges route by the HTTP Host header (h1) / :authority (h2) and ignore the TLS SNI for origin selection, so a censor filtering on SNI sees only an innocent front domain (img.alicdn.com) while the request reaches the real target. Verified 2026-06-23 cross-organization: a TLS session bearing Alibaba's *.tbcdn.cn cert (SNI img.alicdn.com) reached Bilibili (s1.hdslb.com) and Momo (img.momocdn.com) origins purely by Host. This adds aliyun-provider.yaml: a complete, parseable Config with the GlobalSign Root CA - R3 (anchors the Alibaba leaf chain), a verified seed pool of edge IPs across three /24s, and frontingsnis driving img/gw/a .alicdn.com SNIs. hostaliases + testurl are placeholders until Lantern origins are onboarded as Aliyun CDN distributions (the edge silently drops non-customer Host values). aliyun_live_test.go (guarded by DOMAINFRONT_LIVE=1) drives the real roundTripper: every edge completes TLS + GlobalSign verification under the production Chrome_131 hello and fronts a cross-org Host over HTTP/2 (200). Depends on #9 (HTTP/2 fronting) — Aliyun negotiates h2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
♻️ Duplicate comments (1)
h2_test.go (1)
35-35: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winUse context-aware TLS handshake.
The past review comment flagging
srv.Handshake()for lacking explicit timeout control remains valid. Whilenet.Pipe()eliminates network stalls, adoptingHandshakeContextaligns with Go best practices for goroutine lifecycle management.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@h2_test.go` at line 35, Replace the srv.Handshake() call with srv.HandshakeContext() to provide explicit timeout control and align with Go best practices for goroutine lifecycle management. Pass a context with an appropriate timeout as the first argument to HandshakeContext, such as a context derived from context.Background() with a timeout duration to prevent indefinite blocking during the TLS handshake operation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@h2_test.go`:
- Line 35: Replace the srv.Handshake() call with srv.HandshakeContext() to
provide explicit timeout control and align with Go best practices for goroutine
lifecycle management. Pass a context with an appropriate timeout as the first
argument to HandshakeContext, such as a context derived from
context.Background() with a timeout duration to prevent indefinite blocking
during the TLS handshake operation.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 5dcbec52-1989-4ea4-9507-98358c52c141
📒 Files selected for processing (3)
domainfront.goh2_test.goroundtrip.go
🚧 Files skipped from review as they are similar to previous changes (1)
- roundtrip.go
Use HandshakeContext with a 10s deadline for both the server and client handshakes in dialPipeH2 so a broken pairing fails fast on the deadline instead of hanging until the go test timeout. net.Pipe honors deadlines, so the context can interrupt a stalled handshake. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
defer cancel() fired when dialPipeH2 returned, cancelling the handshake context while the returned conn was still in use by the test. On go 1.24 that poisons the conn (past deadline) so the round-trip never reaches the server and the test hangs to timeout (passed on 1.26 which resets the deadline). t.Cleanup(cancel) defers cancellation to test end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1.8.x uses Go 1.24's native ML-KEM (X25519MLKEM768) for Chrome's post-quantum key share instead of vendoring cloudflare/circl, which is why that transitive dep drops out of go.sum — it aligns with the existing go 1.24 requirement. The HelloChrome_131 fingerprint we dial with is unchanged. Verified: full offline suite passes on go 1.24, and HelloChrome_131 still handshakes with real CloudFront and Aliyun edges (negotiating h2 over TLS 1.3) under v1.8.2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When ALPN negotiates HTTP/2, x/net/http2 rejects a request carrying connection-specific headers (Transfer-Encoding, a Connection token other than close/keep-alive, Upgrade) outright, so forwarding caller headers as-is could fail on the h2 path while working on h1. - sendOverConn now strips the connection-specific headers HTTP/2 forbids (RFC 7540 §8.1.2.2), including any header named in Connection, before framing over h2. - A connection-upgrade request (e.g. WebSocket) can't be carried over h2, so it returns errH2UpgradeUnsupported. RoundTrip treats that as "wrong front for this request" — requeues the front as healthy and retries, ideally onto an http/1.1 front that can carry the upgrade — rather than marking the front bad or silently degrading the upgrade to a plain GET. Tests: stripConnHeaders unit test, upgrade-rejected-over-h2 (server not reached), and forbidden-headers-stripped request succeeds over h2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- stripConnHeaders doc: state it mutates req's headers in place and that both callers pass a request they own, instead of over-claiming it never touches the caller's request. - dialPipeH2: close the conn via t.Cleanup so the server goroutine's ServeConn unblocks at test end — covers tests that never send a request (e.g. the rejected-upgrade case), avoiding a goroutine leak. - README: x/net/http2 -> golang.org/x/net/http2 (full import path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Picks up getlantern/domainfront#9, which makes the fronted round trip ALPN-aware: it now frames requests as HTTP/2 when the CDN edge negotiates h2 (CloudFront, Aliyun, ...) instead of speaking HTTP/1.1 over an h2 connection and failing with "malformed HTTP response". Also pulls the utls v1.8.2 bump that ships with it (already the version kindling resolves, so no utls change here). domainfront's public API (New/Config/Provider/Masquerade/ParseConfig/ WithConfigURL) is unchanged, so this is a transparent dependency update. Build, vet, and tests pass. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- domainfront -> v0.0.0-20260624004218-93591749d736 (getlantern/domainfront#9): makes the fronted round trip ALPN-aware, framing requests as HTTP/2 when the CDN edge negotiates h2 (CloudFront, Aliyun, ...) instead of speaking HTTP/1.1 over the h2 connection and failing with a malformed response. - kindling -> v0.0.0-20260624005117-737fcffe2860 (getlantern/kindling#40): the matching kindling bump, which also carries domainfront#9. domainfront's public API is unchanged, so this is a transparent dependency update. The kindling/* and config consumer packages build under both build tag sets and the kindling tests pass. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Propagates the HTTP/2 fronting fix down to the client: - radiance -> v0.0.0-20260624010426-955f5cbfe595 (getlantern/radiance#536) - domainfront -> v0.0.0-20260624004218-93591749d736 (getlantern/domainfront#9, indirect) - kindling -> v0.0.0-20260624005117-737fcffe2860 (getlantern/kindling#40, indirect) - lantern-box -> v0.0.95 (transitive; now required by radiance#536) domainfront#9 makes the fronted round trip ALPN-aware — it frames requests as HTTP/2 when the CDN edge negotiates h2 (CloudFront, Aliyun, ...) instead of speaking HTTP/1.1 over the h2 connection and failing with a malformed response. API-compatible. Ran go mod tidy and committed go.mod+go.sum together so gomobile resolves lantern-box v0.0.95 (not a stale pin). Verified: full Go build under the CI tag set (CGO_ENABLED=1, with_gvisor/ with_quic/with_wireguard/with_utls/with_grpc/with_conntrack), go vet, and lantern-core tests all pass. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Alibaba Cloud / Aliyun CDN edges route by the HTTP Host header (h1) / :authority (h2) and ignore the TLS SNI for origin selection, so a censor filtering on SNI sees only an innocent front domain (img.alicdn.com) while the request reaches the real target. Verified 2026-06-23 cross-organization: a TLS session bearing Alibaba's *.tbcdn.cn cert (SNI img.alicdn.com) reached Bilibili (s1.hdslb.com) and Momo (img.momocdn.com) origins purely by Host. This adds aliyun-provider.yaml: a complete, parseable Config with the GlobalSign Root CA - R3 (anchors the Alibaba leaf chain), a verified seed pool of edge IPs across three /24s, and frontingsnis driving img/gw/a .alicdn.com SNIs. hostaliases + testurl are placeholders until Lantern origins are onboarded as Aliyun CDN distributions (the edge silently drops non-customer Host values). aliyun_live_test.go (guarded by DOMAINFRONT_LIVE=1) drives the real roundTripper: every edge completes TLS + GlobalSign verification under the production Chrome_131 hello and fronts a cross-org Host over HTTP/2 (200). Depends on #9 (HTTP/2 fronting) — Aliyun negotiates h2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Make the fronted round-trip ALPN-aware so it speaks whatever protocol the CDN edge selects — HTTP/1.1 or HTTP/2.
Why
dialFrontoffers a genuine browser ClientHello (Chrome 131), whose ALPN advertisesh2, http/1.1. Many edges answer with h2 — including CloudFront (and Alibaba Cloud / Aliyun). ButdoRequestalways wrapped the connection in an HTTP/1.1-only transport, so the HTTP/1.1 parser choked on the very first HTTP/2 SETTINGS frame:So fronting silently failed against any h2-negotiating edge. The "fix" of advertising only
http/1.1was rejected on purpose: ALPN travels in the cleartext ClientHello, so a Chrome fingerprint that offers onlyhttp/1.1is itself a detectable anomaly. Better to keep the real ALPN and actually speak h2.sequenceDiagram autonumber participant C as Client<br/>dialer.go participant E as Edge<br/>CloudFront / Aliyun participant R as doRequest<br/>roundtrip.go C->>E: ClientHello — ALPN offers h2, http/1.1<br/>dialer.go:78 E-->>C: ServerHello — ALPN selects h2 ⚠️ rect rgba(255, 200, 200, 0.3) Note over R: before: always the HTTP/1.1 transport 🐛<br/>h1 parser chokes on the h2 SETTINGS frame<br/>malformed HTTP response 0x00 0x00 0x12 0x04 end Note over R: now: branch on negotiatedProtocol conn<br/>roundtrip.go:93 alt negotiated h2 R->>E: roundTripH2 — :authority = fronted host<br/>roundtrip.go:100 else http/1.1 or none R->>E: newConnTransport — Host = fronted host<br/>roundtrip.go:106 end E-->>C: response routed by the fronted hostHow
negotiatedProtocol(conn)reads the settled ALPN off the utls connection.h2,roundTripH2frames the request over the already-established conn viax/net/http2'sTransport.NewClientConn. The library dials a fresh connection per request and never reuses it, so theClientConnis single-shot:h2Body.Close()tears down the connection and its frame-reader goroutine when the response body closes.Routing is identical across protocols because
rewriteRequestalready points the request at the fronted host — that lands in theHostheader (HTTP/1.1) or the:authoritypseudo-header (HTTP/2), which is exactly what the CDN routes on.Adds
golang.org/x/netas a direct dependency. It was already pinned atv0.38.0transitively (via utls), so no new version is introduced —go mod tidyonly promotes it.Test plan
h2_test.go— offline, pipe-backed h2 mock server: asserts the fronted host lands in:authority, path + headers are preserved, and the response is HTTP/2. No real network.-race(validates theh2Bodyconnection teardown).Hostover HTTP/2, 200 under the production Chrome_131 hello.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes / Refactor
Tests
Documentation / Chores
Updates during review:
sendOverConn(vetting viaverifyWithPostnow speaks h2 too),h2Body.Closepropagates the connection-close error, andhasConnectionUpgradeparses the comma-separatedConnectiontoken list.HandshakeContext(cancelled at test end viat.Cleanup, not on helper return — the latter poisoned the conn on go 1.24).utlsv1.7.1 → v1.8.2 (uses Go 1.24 native ML-KEM, drops cloudflare/circl; HelloChrome_131 fingerprint unchanged; verified it still handshakes with real CloudFront/Aliyun edges over h2).