From d1ab9e0a8b73f4f2f6255cf4409033a3a327ab93 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 22 Jun 2026 21:52:26 -0600 Subject: [PATCH 1/8] roundtrip: support HTTP/2 fronting via ALPN-aware transport 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) --- README.md | 3 +- go.mod | 2 ++ go.sum | 4 +++ h2_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ roundtrip.go | 68 +++++++++++++++++++++++++++++++++++++++---- 5 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 h2_test.go diff --git a/README.md b/README.md index 1da276f..dd807ae 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ A clean, production-grade domain fronting library for Go. - **Atomic config updates** — `FrontPool.Replace()` swaps the candidate set without unbounded growth, preserving state from previously-working fronts - **Smart round-trip** — checks provider host mapping *before* dialing TLS, avoiding wasted connections - **TLS fingerprinting** — uses [utls](https://github.com/refraction-networking/utls) to mimic real browser Client Hellos (Chrome 131 by default) +- **HTTP/1.1 and HTTP/2** — the fronted request is framed to match whichever protocol the edge selects via ALPN, so the genuine browser ALPN (`h2,http/1.1`) can be offered without breaking against h2-only routing (CloudFront, Aliyun); routing works identically since the fronted host drives the `Host` header or the HTTP/2 `:authority` pseudo-header - **Country-aware SNI** — deterministic SNI selection from per-country lists, derived from IP hash - **Persistent caching** — working fronts are cached to disk (JSON) for fast startup - **Auto-updating config** — optionally fetches updated `fronted.yaml.gz` from a URL every 12 hours -- **Minimal dependencies** — only `utls` and `go-yaml`; no worker pool libraries, no logging frameworks, no custom HTTP fetchers +- **Minimal dependencies** — `utls`, `go-yaml`, and `x/net/http2`; no worker pool libraries, no logging frameworks, no custom HTTP fetchers - **Fully testable** — `Dialer` and `Cache` interfaces; unit tests use pipe-based mock TLS servers, no real CDN infrastructure required ## Installation diff --git a/go.mod b/go.mod index d31782b..7eb1ab7 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/goccy/go-yaml v1.15.13 github.com/refraction-networking/utls v1.7.1 github.com/stretchr/testify v1.11.1 + golang.org/x/net v0.38.0 ) require ( @@ -18,5 +19,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index de456ed..38f2dd8 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/h2_test.go b/h2_test.go new file mode 100644 index 0000000..5b3085e --- /dev/null +++ b/h2_test.go @@ -0,0 +1,82 @@ +package domainfront + +import ( + stdtls "crypto/tls" + "crypto/x509" + "io" + "net" + "net/http" + "testing" + + utls "github.com/refraction-networking/utls" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" +) + +// TestDoRequest_HTTP2 verifies that when the TLS handshake negotiates h2, +// doRequest frames the fronted request as HTTP/2 and the fronted host lands in +// the :authority pseudo-header (the h2 equivalent of the Host header that drives +// CDN routing). It uses a pipe-backed h2 server, so no real network is touched. +func TestDoRequest_HTTP2(t *testing.T) { + ca, caKey := newTestCA(t) + leaf := newTestLeafCert(t, ca, caKey, "cdn.example.com") + roots := x509.NewCertPool() + roots.AddCert(ca) + + type observed struct { + authority string + path string + proto int + hdr string + } + seen := make(chan observed, 1) + + clientRaw, serverRaw := net.Pipe() + go func() { + srv := stdtls.Server(serverRaw, &stdtls.Config{ + Certificates: []stdtls.Certificate{leaf}, + NextProtos: []string{"h2"}, + }) + if err := srv.Handshake(); err != nil { + serverRaw.Close() + return + } + (&http2.Server{}).ServeConn(srv, &http2.ServeConnOpts{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen <- observed{r.Host, r.URL.Path, r.ProtoMajor, r.Header.Get("X-Probe")} + w.WriteHeader(http.StatusOK) + io.WriteString(w, "h2-fronted-ok") + }), + }) + }() + + uconn := utls.UClient(clientRaw, &utls.Config{ + RootCAs: roots, + ServerName: "cdn.example.com", + NextProtos: []string{"h2"}, + }, utls.HelloGolang) + require.NoError(t, uconn.Handshake()) + require.Equal(t, "h2", negotiatedProtocol(uconn), "handshake should negotiate h2") + + req, err := http.NewRequest(http.MethodGet, "https://config.example.com/api/data", nil) + require.NoError(t, err) + req.Header.Set("X-Probe", "carried") + + resp, err := (&roundTripper{}).doRequest(req, uconn, "cdn.example.com", nil) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) // tears down the h2 conn + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 2, resp.ProtoMajor, "response should be HTTP/2") + assert.Equal(t, "h2-fronted-ok", string(body)) + + got := <-seen + assert.Equal(t, "cdn.example.com", got.authority, ":authority must be the fronted host, not the origin") + assert.Equal(t, "/api/data", got.path, "path must be preserved from the original request") + assert.Equal(t, 2, got.proto, "server must see an HTTP/2 request") + assert.Equal(t, "carried", got.hdr, "caller headers must propagate") +} diff --git a/roundtrip.go b/roundtrip.go index 8d1cfb4..aa00f34 100644 --- a/roundtrip.go +++ b/roundtrip.go @@ -7,7 +7,11 @@ import ( "net" "net/http" "strings" + "sync" "time" + + utls "github.com/refraction-networking/utls" + "golang.org/x/net/http2" ) // roundTripper is an http.RoundTripper that sends requests via domain fronting. @@ -84,13 +88,23 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { func (rt *roundTripper) doRequest(req *http.Request, conn net.Conn, frontedHost string, body io.ReadCloser) (*http.Response, error) { fronted := rewriteRequest(req, frontedHost, body) - disableKeepAlives := true - if strings.EqualFold(req.Header.Get("Connection"), "upgrade") { - disableKeepAlives = false + var resp *http.Response + var err error + if negotiatedProtocol(conn) == "h2" { + // The edge selected HTTP/2 via ALPN (CloudFront and Aliyun both do + // under a real-browser ClientHello). Frame the request as h2 over the + // already-established conn. rewriteRequest set the scheme to "http" for + // the h1 transport's benefit; restore "https" so the :authority/:scheme + // pseudo-headers match what a browser sends over TLS. + fronted.URL.Scheme = "https" + resp, err = roundTripH2(conn, fronted) + } else { + disableKeepAlives := true + if strings.EqualFold(req.Header.Get("Connection"), "upgrade") { + disableKeepAlives = false + } + resp, err = newConnTransport(conn, disableKeepAlives).RoundTrip(fronted) } - - tr := newConnTransport(conn, disableKeepAlives) - resp, err := tr.RoundTrip(fronted) if err != nil { return nil, err } @@ -104,6 +118,48 @@ func (rt *roundTripper) doRequest(req *http.Request, conn net.Conn, frontedHost return resp, nil } +// negotiatedProtocol reports the ALPN protocol settled during the TLS +// handshake ("h2", "http/1.1", or "" when none was negotiated). In production +// conn is always the utls client connection from dialFront. +func negotiatedProtocol(conn net.Conn) string { + if c, ok := conn.(*utls.UConn); ok { + return c.ConnectionState().NegotiatedProtocol + } + return "" +} + +// roundTripH2 sends req over conn using HTTP/2 framing. The library dials a +// fresh connection per request and never reuses it, so the h2 ClientConn is +// single-shot: closing the response body tears down the connection (and the +// frame-reader goroutine) instead of leaving it for h2's idle pool. +func roundTripH2(conn net.Conn, req *http.Request) (*http.Response, error) { + cc, err := (&http2.Transport{}).NewClientConn(conn) + if err != nil { + return nil, err + } + resp, err := cc.RoundTrip(req) + if err != nil { + cc.Close() + return nil, err + } + resp.Body = &h2Body{ReadCloser: resp.Body, cc: cc} + return resp, nil +} + +// h2Body closes the underlying HTTP/2 connection when the response body is +// closed, since each connection serves exactly one request. +type h2Body struct { + io.ReadCloser + cc *http2.ClientConn + once sync.Once +} + +func (b *h2Body) Close() error { + err := b.ReadCloser.Close() + b.once.Do(func() { b.cc.Close() }) + return err +} + // newConnTransport creates an http.RoundTripper that sends requests over a // pre-established connection. The request URL scheme must already be "http" // (set by rewriteRequest) since TLS is already established. From f330270742470af0a843cbbb4df5a2aaf44600b0 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 22 Jun 2026 22:00:33 -0600 Subject: [PATCH 2/8] =?UTF-8?q?roundtrip:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20vet=20over=20h2,=20propagate=20close=20err,=20Conne?= =?UTF-8?q?ction=20token=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- domainfront.go | 22 +++++++------- h2_test.go | 79 ++++++++++++++++++++++++++++++++++++-------------- roundtrip.go | 64 +++++++++++++++++++++++++++------------- 3 files changed, 113 insertions(+), 52 deletions(-) diff --git a/domainfront.go b/domainfront.go index 84481de..392424d 100644 --- a/domainfront.go +++ b/domainfront.go @@ -66,14 +66,14 @@ type Client struct { // Option configures a Client. type Option func(*Client) -func WithLogger(l *slog.Logger) Option { return func(c *Client) { c.log = l } } -func WithDialer(d Dialer) Option { return func(c *Client) { c.dialer = d } } -func WithCountryCode(cc string) Option { return func(c *Client) { c.countryCode = cc } } -func WithDefaultProviderID(id string) Option { return func(c *Client) { c.defaultPID = id } } -func WithConfigURL(url string) Option { return func(c *Client) { c.configURL = url } } -func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } } -func WithCache(cache Cache) Option { return func(c *Client) { c.cache = cache } } -func WithMaxRetries(n int) Option { return func(c *Client) { c.maxRetries = n } } +func WithLogger(l *slog.Logger) Option { return func(c *Client) { c.log = l } } +func WithDialer(d Dialer) Option { return func(c *Client) { c.dialer = d } } +func WithCountryCode(cc string) Option { return func(c *Client) { c.countryCode = cc } } +func WithDefaultProviderID(id string) Option { return func(c *Client) { c.defaultPID = id } } +func WithConfigURL(url string) Option { return func(c *Client) { c.configURL = url } } +func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } } +func WithCache(cache Cache) Option { return func(c *Client) { c.cache = cache } } +func WithMaxRetries(n int) Option { return func(c *Client) { c.maxRetries = n } } func WithClientHelloID(id tls.ClientHelloID) Option { return func(c *Client) { c.clientHelloID = id } } @@ -328,15 +328,15 @@ func (c *Client) vetFront(f *front) bool { var vetBody = []byte("a") func (c *Client) verifyWithPost(conn net.Conn, testURL string) bool { - tr := newConnTransport(conn, true) req, err := http.NewRequest(http.MethodPost, testURL, bytes.NewReader(vetBody)) if err != nil { c.log.Debug("Error creating vet request", "error", err) return false } - req.URL.Scheme = "http" // TLS already established on conn req.Header.Set("Content-Type", "application/json") - resp, err := tr.RoundTrip(req) + // Frame the vet request to match the negotiated ALPN — the dialed front + // may have negotiated h2, exactly like real request traffic. + resp, err := sendOverConn(conn, req, true) if err != nil { c.log.Debug("Error vetting front", "error", err, "url", testURL) return false diff --git a/h2_test.go b/h2_test.go index 5b3085e..2ace6f0 100644 --- a/h2_test.go +++ b/h2_test.go @@ -4,6 +4,7 @@ import ( stdtls "crypto/tls" "crypto/x509" "io" + "log/slog" "net" "net/http" "testing" @@ -14,24 +15,16 @@ import ( "golang.org/x/net/http2" ) -// TestDoRequest_HTTP2 verifies that when the TLS handshake negotiates h2, -// doRequest frames the fronted request as HTTP/2 and the fronted host lands in -// the :authority pseudo-header (the h2 equivalent of the Host header that drives -// CDN routing). It uses a pipe-backed h2 server, so no real network is touched. -func TestDoRequest_HTTP2(t *testing.T) { +// dialPipeH2 stands up an HTTP/2 server on one end of a net.Pipe (TLS with +// ALPN "h2") and returns a handshaken utls client connection to it — the same +// connection type dialFront produces in production. No real network is used. +func dialPipeH2(t *testing.T, handler http.Handler) *utls.UConn { + t.Helper() ca, caKey := newTestCA(t) leaf := newTestLeafCert(t, ca, caKey, "cdn.example.com") roots := x509.NewCertPool() roots.AddCert(ca) - type observed struct { - authority string - path string - proto int - hdr string - } - seen := make(chan observed, 1) - clientRaw, serverRaw := net.Pipe() go func() { srv := stdtls.Server(serverRaw, &stdtls.Config{ @@ -42,13 +35,7 @@ func TestDoRequest_HTTP2(t *testing.T) { serverRaw.Close() return } - (&http2.Server{}).ServeConn(srv, &http2.ServeConnOpts{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seen <- observed{r.Host, r.URL.Path, r.ProtoMajor, r.Header.Get("X-Probe")} - w.WriteHeader(http.StatusOK) - io.WriteString(w, "h2-fronted-ok") - }), - }) + (&http2.Server{}).ServeConn(srv, &http2.ServeConnOpts{Handler: handler}) }() uconn := utls.UClient(clientRaw, &utls.Config{ @@ -58,12 +45,30 @@ func TestDoRequest_HTTP2(t *testing.T) { }, utls.HelloGolang) require.NoError(t, uconn.Handshake()) require.Equal(t, "h2", negotiatedProtocol(uconn), "handshake should negotiate h2") + return uconn +} + +// TestDoRequest_HTTP2 verifies that when the TLS handshake negotiates h2, +// doRequest frames the fronted request as HTTP/2 and the fronted host lands in +// the :authority pseudo-header (the h2 equivalent of the Host header that drives +// CDN routing). +func TestDoRequest_HTTP2(t *testing.T) { + type observed struct { + authority, path, hdr string + proto int + } + seen := make(chan observed, 1) + conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen <- observed{r.Host, r.URL.Path, r.Header.Get("X-Probe"), r.ProtoMajor} + w.WriteHeader(http.StatusOK) + io.WriteString(w, "h2-fronted-ok") + })) req, err := http.NewRequest(http.MethodGet, "https://config.example.com/api/data", nil) require.NoError(t, err) req.Header.Set("X-Probe", "carried") - resp, err := (&roundTripper{}).doRequest(req, uconn, "cdn.example.com", nil) + resp, err := (&roundTripper{}).doRequest(req, conn, "cdn.example.com", nil) require.NoError(t, err) body, err := io.ReadAll(resp.Body) @@ -80,3 +85,35 @@ func TestDoRequest_HTTP2(t *testing.T) { assert.Equal(t, 2, got.proto, "server must see an HTTP/2 request") assert.Equal(t, "carried", got.hdr, "caller headers must propagate") } + +// TestVerifyWithPost_HTTP2 covers the front-vetting path over an h2 connection. +// Vetting gates whether a front becomes usable, so it must speak h2 just like +// request traffic — otherwise every h2 edge (CloudFront, Aliyun) fails to vet. +func TestVerifyWithPost_HTTP2(t *testing.T) { + gotMethod := make(chan string, 1) + conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod <- r.Method + w.WriteHeader(http.StatusAccepted) // 202: the contract verifyWithPost checks + })) + + c := &Client{log: slog.Default()} + ok := c.verifyWithPost(conn, "https://cdn.example.com/ping") + + assert.True(t, ok, "a 202 over h2 should vet successfully") + assert.Equal(t, http.MethodPost, <-gotMethod) +} + +func TestHasConnectionUpgrade(t *testing.T) { + mk := func(vals ...string) *http.Request { + r, _ := http.NewRequest(http.MethodGet, "https://x/", nil) + for _, v := range vals { + r.Header.Add("Connection", v) + } + return r + } + assert.True(t, hasConnectionUpgrade(mk("upgrade"))) + assert.True(t, hasConnectionUpgrade(mk("keep-alive, Upgrade")), "token in a comma list") + assert.True(t, hasConnectionUpgrade(mk("keep-alive", "Upgrade")), "multiple header values") + assert.False(t, hasConnectionUpgrade(mk("keep-alive"))) + assert.False(t, hasConnectionUpgrade(mk())) +} diff --git a/roundtrip.go b/roundtrip.go index aa00f34..b122fd4 100644 --- a/roundtrip.go +++ b/roundtrip.go @@ -88,23 +88,10 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { func (rt *roundTripper) doRequest(req *http.Request, conn net.Conn, frontedHost string, body io.ReadCloser) (*http.Response, error) { fronted := rewriteRequest(req, frontedHost, body) - var resp *http.Response - var err error - if negotiatedProtocol(conn) == "h2" { - // The edge selected HTTP/2 via ALPN (CloudFront and Aliyun both do - // under a real-browser ClientHello). Frame the request as h2 over the - // already-established conn. rewriteRequest set the scheme to "http" for - // the h1 transport's benefit; restore "https" so the :authority/:scheme - // pseudo-headers match what a browser sends over TLS. - fronted.URL.Scheme = "https" - resp, err = roundTripH2(conn, fronted) - } else { - disableKeepAlives := true - if strings.EqualFold(req.Header.Get("Connection"), "upgrade") { - disableKeepAlives = false - } - resp, err = newConnTransport(conn, disableKeepAlives).RoundTrip(fronted) - } + // One connection per request, so HTTP/1.1 keep-alives are disabled — unless + // this is a protocol upgrade (e.g. WebSocket), which needs the connection + // left intact for hijacking. + resp, err := sendOverConn(conn, fronted, !hasConnectionUpgrade(req)) if err != nil { return nil, err } @@ -118,6 +105,25 @@ func (rt *roundTripper) doRequest(req *http.Request, conn net.Conn, frontedHost return resp, nil } +// sendOverConn sends req over the already-established TLS conn, framing it to +// match the protocol negotiated via ALPN: HTTP/2 when the edge selected "h2" +// (CloudFront, Aliyun, ...), HTTP/1.1 otherwise. Every caller that speaks over +// a dialed front — request round-trips and front vetting alike — must go +// through here so the wire framing stays consistent with the negotiated ALPN; +// unconditionally speaking HTTP/1.1 over an h2 connection yields a "malformed +// HTTP response" on the first h2 frame. +func sendOverConn(conn net.Conn, req *http.Request, disableKeepAlives bool) (*http.Response, error) { + if negotiatedProtocol(conn) == "h2" { + // Restore "https" so the :scheme/:authority pseudo-headers match what a + // browser sends over TLS; roundTripH2 frames over the existing conn. + req.URL.Scheme = "https" + return roundTripH2(conn, req) + } + // TLS is already established on conn, so "http" avoids a second handshake. + req.URL.Scheme = "http" + return newConnTransport(conn, disableKeepAlives).RoundTrip(req) +} + // negotiatedProtocol reports the ALPN protocol settled during the TLS // handshake ("h2", "http/1.1", or "" when none was negotiated). In production // conn is always the utls client connection from dialFront. @@ -128,6 +134,20 @@ func negotiatedProtocol(conn net.Conn) string { return "" } +// hasConnectionUpgrade reports whether the request's Connection header lists an +// "upgrade" token. Connection is a comma-separated token list, so a plain +// equality check would miss common values like "keep-alive, Upgrade". +func hasConnectionUpgrade(req *http.Request) bool { + for _, v := range req.Header.Values("Connection") { + for tok := range strings.SplitSeq(v, ",") { + if strings.EqualFold(strings.TrimSpace(tok), "upgrade") { + return true + } + } + } + return false +} + // roundTripH2 sends req over conn using HTTP/2 framing. The library dials a // fresh connection per request and never reuses it, so the h2 ClientConn is // single-shot: closing the response body tears down the connection (and the @@ -155,9 +175,13 @@ type h2Body struct { } func (b *h2Body) Close() error { - err := b.ReadCloser.Close() - b.once.Do(func() { b.cc.Close() }) - return err + bodyErr := b.ReadCloser.Close() + var ccErr error + b.once.Do(func() { ccErr = b.cc.Close() }) + if bodyErr != nil { + return bodyErr + } + return ccErr } // newConnTransport creates an http.RoundTripper that sends requests over a From 05800022d909a2fc9fded488529deae841016372 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 22 Jun 2026 22:01:40 -0600 Subject: [PATCH 3/8] roundtrip: unit-test h2Body close-error propagation 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) --- h2_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ roundtrip.go | 5 +++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/h2_test.go b/h2_test.go index 2ace6f0..1018376 100644 --- a/h2_test.go +++ b/h2_test.go @@ -3,6 +3,7 @@ package domainfront import ( stdtls "crypto/tls" "crypto/x509" + "errors" "io" "log/slog" "net" @@ -103,6 +104,47 @@ func TestVerifyWithPost_HTTP2(t *testing.T) { assert.Equal(t, http.MethodPost, <-gotMethod) } +// errCloser is a test io.Closer that records call count and returns a fixed err. +type errCloser struct { + err error + calls int +} + +func (c *errCloser) Close() error { c.calls++; return c.err } + +// TestH2Body_Close covers the connection-teardown semantics: the underlying h2 +// connection is closed exactly once, its error surfaces when the body close +// itself succeeds, and a body-close error takes precedence. +func TestH2Body_Close(t *testing.T) { + t.Run("propagates cc error when body close ok", func(t *testing.T) { + cc := &errCloser{err: assert.AnError} + b := &h2Body{ReadCloser: io.NopCloser(nil), cc: cc} + assert.Equal(t, assert.AnError, b.Close()) + assert.Equal(t, 1, cc.calls) + }) + + t.Run("body error takes precedence over cc error", func(t *testing.T) { + bodyErr := errors.New("body boom") + cc := &errCloser{err: assert.AnError} + b := &h2Body{ReadCloser: errReadCloser{bodyErr}, cc: cc} + assert.Equal(t, bodyErr, b.Close()) + assert.Equal(t, 1, cc.calls, "cc still closed even when body close fails") + }) + + t.Run("closes the connection only once", func(t *testing.T) { + cc := &errCloser{} + b := &h2Body{ReadCloser: io.NopCloser(nil), cc: cc} + assert.NoError(t, b.Close()) + assert.NoError(t, b.Close()) + assert.Equal(t, 1, cc.calls) + }) +} + +type errReadCloser struct{ err error } + +func (e errReadCloser) Read([]byte) (int, error) { return 0, e.err } +func (e errReadCloser) Close() error { return e.err } + func TestHasConnectionUpgrade(t *testing.T) { mk := func(vals ...string) *http.Request { r, _ := http.NewRequest(http.MethodGet, "https://x/", nil) diff --git a/roundtrip.go b/roundtrip.go index b122fd4..6caa6d4 100644 --- a/roundtrip.go +++ b/roundtrip.go @@ -167,10 +167,11 @@ func roundTripH2(conn net.Conn, req *http.Request) (*http.Response, error) { } // h2Body closes the underlying HTTP/2 connection when the response body is -// closed, since each connection serves exactly one request. +// closed, since each connection serves exactly one request. cc is an io.Closer +// (a *http2.ClientConn in practice) so the teardown logic stays unit-testable. type h2Body struct { io.ReadCloser - cc *http2.ClientConn + cc io.Closer once sync.Once } From 7701fe63cb3ac1c7a2b3356e07905b4d4de3d955 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 23 Jun 2026 15:49:48 -0600 Subject: [PATCH 4/8] h2 test: bound handshakes with HandshakeContext (CodeRabbit) 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 --- h2_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/h2_test.go b/h2_test.go index 1018376..5f245bd 100644 --- a/h2_test.go +++ b/h2_test.go @@ -1,6 +1,7 @@ package domainfront import ( + "context" stdtls "crypto/tls" "crypto/x509" "errors" @@ -9,6 +10,7 @@ import ( "net" "net/http" "testing" + "time" utls "github.com/refraction-networking/utls" "github.com/stretchr/testify/assert" @@ -26,13 +28,19 @@ func dialPipeH2(t *testing.T, handler http.Handler) *utls.UConn { roots := x509.NewCertPool() roots.AddCert(ca) + // Bound both handshakes so a broken pairing fails fast on the deadline + // rather than hanging until the `go test` timeout. net.Pipe honors + // deadlines, so HandshakeContext can interrupt a stalled handshake. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + clientRaw, serverRaw := net.Pipe() go func() { srv := stdtls.Server(serverRaw, &stdtls.Config{ Certificates: []stdtls.Certificate{leaf}, NextProtos: []string{"h2"}, }) - if err := srv.Handshake(); err != nil { + if err := srv.HandshakeContext(ctx); err != nil { serverRaw.Close() return } @@ -44,7 +52,7 @@ func dialPipeH2(t *testing.T, handler http.Handler) *utls.UConn { ServerName: "cdn.example.com", NextProtos: []string{"h2"}, }, utls.HelloGolang) - require.NoError(t, uconn.Handshake()) + require.NoError(t, uconn.HandshakeContext(ctx)) require.Equal(t, "h2", negotiatedProtocol(uconn), "handshake should negotiate h2") return uconn } From 3c1e65176963a68e59f6429d47c2c148c6c75d2b Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 23 Jun 2026 15:56:03 -0600 Subject: [PATCH 5/8] h2 test: cancel handshake context at test end, not on helper return 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 --- h2_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/h2_test.go b/h2_test.go index 5f245bd..8e0dbf6 100644 --- a/h2_test.go +++ b/h2_test.go @@ -31,8 +31,11 @@ func dialPipeH2(t *testing.T, handler http.Handler) *utls.UConn { // Bound both handshakes so a broken pairing fails fast on the deadline // rather than hanging until the `go test` timeout. net.Pipe honors // deadlines, so HandshakeContext can interrupt a stalled handshake. + // Cancel at test end (not on return): the returned conn is used after this + // helper returns, and cancelling the handshake context while the conn is + // still in use poisons it (sets a past deadline) on some Go versions. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() + t.Cleanup(cancel) clientRaw, serverRaw := net.Pipe() go func() { From 510f3c79dbeca7e0ed5f81c8dfcc500b9f0be2e9 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 23 Jun 2026 15:57:52 -0600 Subject: [PATCH 6/8] deps: bump utls v1.7.1 -> v1.8.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 3 +-- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 7eb1ab7..dc4ce04 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,13 @@ toolchain go1.24.1 require ( github.com/goccy/go-yaml v1.15.13 - github.com/refraction-networking/utls v1.7.1 + github.com/refraction-networking/utls v1.8.2 github.com/stretchr/testify v1.11.1 golang.org/x/net v0.38.0 ) require ( github.com/andybalholm/brotli v1.0.6 // indirect - github.com/cloudflare/circl v1.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index 38f2dd8..07bae12 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys= -github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-yaml v1.15.13 h1:Xd87Yddmr2rC1SLLTm2MNDcTjeO/GYo0JGiww6gSTDg= @@ -10,8 +8,8 @@ github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/refraction-networking/utls v1.7.1 h1:dxg+jla3uocgN8HtX+ccwDr68uCBBO3qLrkZUbqkcw0= -github.com/refraction-networking/utls v1.7.1/go.mod h1:TUhh27RHMGtQvjQq+RyO11P6ZNQNBb3N0v7wsEjKAIQ= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= From 1b9215c76e32a59296a26c5fe172af25f9ba3e82 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 23 Jun 2026 18:16:03 -0600 Subject: [PATCH 7/8] roundtrip: strip h2-forbidden headers, reject upgrades over h2 (Copilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- h2_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ roundtrip.go | 43 ++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/h2_test.go b/h2_test.go index 8e0dbf6..278c36c 100644 --- a/h2_test.go +++ b/h2_test.go @@ -115,6 +115,74 @@ func TestVerifyWithPost_HTTP2(t *testing.T) { assert.Equal(t, http.MethodPost, <-gotMethod) } +// TestStripConnHeaders verifies the RFC 7540 §8.1.2.2 transformation: the +// connection-specific headers and any header named in Connection are removed, +// while ordinary headers are preserved. +func TestStripConnHeaders(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://x/y", nil) + require.NoError(t, err) + req.Header.Set("Connection", "keep-alive, X-Hop") + req.Header.Set("X-Hop", "drop") // named in Connection + req.Header.Set("Keep-Alive", "timeout=5") + req.Header.Set("Transfer-Encoding", "chunked") + req.Header.Set("Upgrade", "h2c") + req.Header.Set("X-Keep", "stay") + + stripConnHeaders(req) + + for _, h := range []string{"Connection", "X-Hop", "Keep-Alive", "Transfer-Encoding", "Upgrade"} { + assert.Emptyf(t, req.Header.Get(h), "%s must be stripped", h) + } + assert.Equal(t, "stay", req.Header.Get("X-Keep"), "non-connection headers must remain") +} + +// TestSendOverConn_H2_RejectsUpgrade verifies an upgrade request over an h2 +// front is rejected with errH2UpgradeUnsupported (so RoundTrip can retry onto +// an http/1.1 front) and never reaches the server. +func TestSendOverConn_H2_RejectsUpgrade(t *testing.T) { + reached := make(chan struct{}, 1) + conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached <- struct{}{} + w.WriteHeader(http.StatusOK) + })) + req, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/ws", nil) + require.NoError(t, err) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + + _, err = sendOverConn(conn, req, false) + require.ErrorIs(t, err, errH2UpgradeUnsupported) + select { + case <-reached: + t.Fatal("server must not be reached for an upgrade rejected over h2") + default: + } +} + +// TestSendOverConn_H2_StripsForbiddenHeaders verifies that connection-specific +// headers (which x/net/http2 would otherwise reject) are stripped before h2 +// framing, so an otherwise-valid request still succeeds. +func TestSendOverConn_H2_StripsForbiddenHeaders(t *testing.T) { + gotHop := make(chan string, 1) + conn := dialPipeH2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHop <- r.Header.Get("X-Hop") + w.WriteHeader(http.StatusOK) + })) + req, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/x", nil) + require.NoError(t, err) + // Transfer-Encoding: gzip and a non-close/keep-alive Connection token both + // make x/net/http2 reject the request unless stripped first. + req.Header.Set("Transfer-Encoding", "gzip") + req.Header.Set("Connection", "X-Hop") + req.Header.Set("X-Hop", "should-be-stripped") + + resp, err := sendOverConn(conn, req, true) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Empty(t, <-gotHop, "Connection-named header must be stripped before h2") +} + // errCloser is a test io.Closer that records call count and returns a fixed err. type errCloser struct { err error diff --git a/roundtrip.go b/roundtrip.go index 6caa6d4..e8dc789 100644 --- a/roundtrip.go +++ b/roundtrip.go @@ -2,6 +2,7 @@ package domainfront import ( "bytes" + "errors" "fmt" "io" "net" @@ -14,6 +15,12 @@ import ( "golang.org/x/net/http2" ) +// errH2UpgradeUnsupported is returned when a connection-upgrade request (e.g. +// WebSocket) lands on a front whose ALPN negotiated HTTP/2. h2 has no h1-style +// Upgrade mechanism, so RoundTrip treats this as "wrong front for this request" +// rather than a front failure and retries onto another (ideally http/1.1) front. +var errH2UpgradeUnsupported = errors.New("connection upgrade not supported over HTTP/2 front") + // roundTripper is an http.RoundTripper that sends requests via domain fronting. // It takes a front from the pool, checks the provider mapping, dials TLS, // rewrites the request, and retries on failure. @@ -70,7 +77,10 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := rt.doRequest(req, result.conn, frontedHost, body) if err != nil { - rt.client.pool.Return(f, false) + // An upgrade landing on an h2 front isn't the front's fault, so + // requeue it as healthy and retry — ideally onto an http/1.1 front + // that can carry the upgrade. All other errors fail the front. + rt.client.pool.Return(f, errors.Is(err, errH2UpgradeUnsupported)) rt.client.notifyCacheDirty() result.conn.Close() lastErr = err @@ -114,6 +124,19 @@ func (rt *roundTripper) doRequest(req *http.Request, conn net.Conn, frontedHost // HTTP response" on the first h2 frame. func sendOverConn(conn net.Conn, req *http.Request, disableKeepAlives bool) (*http.Response, error) { if negotiatedProtocol(conn) == "h2" { + // An h1-style upgrade can't be carried over h2 (it needs Extended + // CONNECT, which this single-shot transport doesn't set up), and + // x/net/http2 rejects the Upgrade header outright. Signal it so + // RoundTrip can retry onto an http/1.1 front instead of silently + // degrading the upgrade to a plain request. + if hasConnectionUpgrade(req) { + return nil, errH2UpgradeUnsupported + } + // Strip the connection-specific headers HTTP/2 forbids (RFC 7540 + // §8.1.2.2). x/net/http2 errors on e.g. Transfer-Encoding or a + // Connection token other than close/keep-alive, so they must be removed + // before framing — they're meaningless on a multiplexed h2 stream anyway. + stripConnHeaders(req) // Restore "https" so the :scheme/:authority pseudo-headers match what a // browser sends over TLS; roundTripH2 frames over the existing conn. req.URL.Scheme = "https" @@ -148,6 +171,24 @@ func hasConnectionUpgrade(req *http.Request) bool { return false } +// stripConnHeaders removes the connection-specific header fields HTTP/2 forbids +// (RFC 7540 §8.1.2.2). Any field named in the Connection header is itself +// connection-specific and removed too. Without this, x/net/http2 rejects a +// request carrying e.g. Transfer-Encoding or a non-close/keep-alive Connection +// token. It mutates the already-copied fronted request, never the caller's. +func stripConnHeaders(req *http.Request) { + for _, v := range req.Header.Values("Connection") { + for tok := range strings.SplitSeq(v, ",") { + if name := strings.TrimSpace(tok); name != "" { + req.Header.Del(name) + } + } + } + for _, h := range []string{"Connection", "Proxy-Connection", "Keep-Alive", "Transfer-Encoding", "Upgrade"} { + req.Header.Del(h) + } +} + // roundTripH2 sends req over conn using HTTP/2 framing. The library dials a // fresh connection per request and never reuses it, so the h2 ClientConn is // single-shot: closing the response body tears down the connection (and the From dd1667038aa20c5920d22495f7cdb31c03c73a67 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 23 Jun 2026 18:40:02 -0600 Subject: [PATCH 8/8] roundtrip/test/docs: address Copilot review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 2 +- h2_test.go | 5 +++++ roundtrip.go | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dd807ae..bd1beee 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A clean, production-grade domain fronting library for Go. - **Country-aware SNI** — deterministic SNI selection from per-country lists, derived from IP hash - **Persistent caching** — working fronts are cached to disk (JSON) for fast startup - **Auto-updating config** — optionally fetches updated `fronted.yaml.gz` from a URL every 12 hours -- **Minimal dependencies** — `utls`, `go-yaml`, and `x/net/http2`; no worker pool libraries, no logging frameworks, no custom HTTP fetchers +- **Minimal dependencies** — `utls`, `go-yaml`, and `golang.org/x/net/http2`; no worker pool libraries, no logging frameworks, no custom HTTP fetchers - **Fully testable** — `Dialer` and `Cache` interfaces; unit tests use pipe-based mock TLS servers, no real CDN infrastructure required ## Installation diff --git a/h2_test.go b/h2_test.go index 278c36c..9c7f814 100644 --- a/h2_test.go +++ b/h2_test.go @@ -57,6 +57,11 @@ func dialPipeH2(t *testing.T, handler http.Handler) *utls.UConn { }, utls.HelloGolang) require.NoError(t, uconn.HandshakeContext(ctx)) require.Equal(t, "h2", negotiatedProtocol(uconn), "handshake should negotiate h2") + // Close the conn at test end so the server goroutine's ServeConn unblocks + // (it otherwise reads the pipe until close). Tests that complete a request + // already tear the conn down via resp.Body.Close; this also covers tests + // that never send one (e.g. an upgrade rejected before any round-trip). + t.Cleanup(func() { _ = uconn.Close(); _ = serverRaw.Close() }) return uconn } diff --git a/roundtrip.go b/roundtrip.go index e8dc789..23ed168 100644 --- a/roundtrip.go +++ b/roundtrip.go @@ -175,7 +175,9 @@ func hasConnectionUpgrade(req *http.Request) bool { // (RFC 7540 §8.1.2.2). Any field named in the Connection header is itself // connection-specific and removed too. Without this, x/net/http2 rejects a // request carrying e.g. Transfer-Encoding or a non-close/keep-alive Connection -// token. It mutates the already-copied fronted request, never the caller's. +// token. It mutates req's headers in place; both callers pass a request they +// own (doRequest the fronted copy from rewriteRequest, verifyWithPost a freshly +// built vetting request), so no shared or caller-owned request is affected. func stripConnHeaders(req *http.Request) { for _, v := range req.Header.Values("Connection") { for tok := range strings.SplitSeq(v, ",") {