diff --git a/README.md b/README.md index 1da276f..bd1beee 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 `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/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/go.mod b/go.mod index d31782b..dc4ce04 100644 --- a/go.mod +++ b/go.mod @@ -6,17 +6,18 @@ 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 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..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,14 +8,18 @@ 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= 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..9c7f814 --- /dev/null +++ b/h2_test.go @@ -0,0 +1,245 @@ +package domainfront + +import ( + "context" + stdtls "crypto/tls" + "crypto/x509" + "errors" + "io" + "log/slog" + "net" + "net/http" + "testing" + "time" + + utls "github.com/refraction-networking/utls" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" +) + +// 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) + + // 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) + t.Cleanup(cancel) + + clientRaw, serverRaw := net.Pipe() + go func() { + srv := stdtls.Server(serverRaw, &stdtls.Config{ + Certificates: []stdtls.Certificate{leaf}, + NextProtos: []string{"h2"}, + }) + if err := srv.HandshakeContext(ctx); err != nil { + serverRaw.Close() + return + } + (&http2.Server{}).ServeConn(srv, &http2.ServeConnOpts{Handler: handler}) + }() + + uconn := utls.UClient(clientRaw, &utls.Config{ + RootCAs: roots, + ServerName: "cdn.example.com", + NextProtos: []string{"h2"}, + }, 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 +} + +// 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, conn, "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") +} + +// 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) +} + +// 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 + 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) + 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 8d1cfb4..23ed168 100644 --- a/roundtrip.go +++ b/roundtrip.go @@ -2,14 +2,25 @@ package domainfront import ( "bytes" + "errors" "fmt" "io" "net" "net/http" "strings" + "sync" "time" + + utls "github.com/refraction-networking/utls" + "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. @@ -66,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 @@ -84,13 +98,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) - disableKeepAlives := true - if strings.EqualFold(req.Header.Get("Connection"), "upgrade") { - disableKeepAlives = false - } - - tr := newConnTransport(conn, disableKeepAlives) - resp, err := tr.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 } @@ -104,6 +115,119 @@ 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" { + // 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" + 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. +func negotiatedProtocol(conn net.Conn) string { + if c, ok := conn.(*utls.UConn); ok { + return c.ConnectionState().NegotiatedProtocol + } + 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 +} + +// 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 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, ",") { + 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 +// 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. cc is an io.Closer +// (a *http2.ClientConn in practice) so the teardown logic stays unit-testable. +type h2Body struct { + io.ReadCloser + cc io.Closer + once sync.Once +} + +func (b *h2Body) Close() error { + 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 // pre-established connection. The request URL scheme must already be "http" // (set by rewriteRequest) since TLS is already established.