diff --git a/go.mod b/go.mod index f542f92d..069bfd53 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/yuin/goldmark/v2 v2.0.2 golang.org/x/crypto v0.57.0 golang.org/x/image v0.46.0 - golang.org/x/net v0.58.0 + golang.org/x/net v0.59.0 golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 golang.org/x/text v0.42.0 diff --git a/go.sum b/go.sum index c59e3b0b..d9395c7f 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= diff --git a/internal/client/client.go b/internal/client/client.go index df254560..5f472c02 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -29,7 +29,6 @@ import ( "github.com/klauspost/compress/zstd" "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" - "golang.org/x/net/http2" ) // Client represents a wrapped HTTP client. @@ -591,23 +590,6 @@ func wrapDialWithConnectTimeout(baseDial func(context.Context, string, string) ( } } -func getHTTP2Transport(baseDial func(context.Context, string, string) (net.Conn, error), res *resolver.Resolver, explicitProxy *url.URL, tlsConfig *tls.Config, connectTimeout time.Duration, echMode core.ECHMode) http.RoundTripper { - return &http2.Transport{ - AllowHTTP: false, - DialTLSContext: newHTTP2DialTLS(baseDial, res, explicitProxy, "https", tlsConfig, connectTimeout, echMode), - DisableCompression: true, - TLSClientConfig: tlsConfig.Clone(), - } -} - -func getH2CTransport(baseDial func(context.Context, string, string) (net.Conn, error), res *resolver.Resolver, explicitProxy *url.URL, connectTimeout time.Duration) http.RoundTripper { - return &http2.Transport{ - AllowHTTP: true, - DialTLSContext: newHTTP2DialTLS(baseDial, res, explicitProxy, "http", nil, connectTimeout, core.ECHOff), - DisableCompression: true, - } -} - func getHTTP3Transport(res *resolver.Resolver, tlsConfig *tls.Config, connectTimeout time.Duration, echMode core.ECHMode) http.RoundTripper { rt := &http3.Transport{ DisableCompression: true, diff --git a/internal/client/http2_proxy.go b/internal/client/http2_proxy.go deleted file mode 100644 index 1130a52b..00000000 --- a/internal/client/http2_proxy.go +++ /dev/null @@ -1,201 +0,0 @@ -package client - -import ( - "bufio" - "context" - "crypto/tls" - "encoding/base64" - "fmt" - "net" - "net/http" - "net/url" - "strings" - "time" - - "github.com/ryanfowler/fetch/internal/core" - "github.com/ryanfowler/fetch/internal/resolver" -) - -// newHTTP2DialTLS returns the first-hop-aware dialer used by x/net/http2. -// Unlike http.Transport, x/net/http2.Transport has no Proxy field, so the -// CONNECT/SOCKS operation is kept here and the origin TLS configuration is -// applied only after the proxy tunnel is ready. -func newHTTP2DialTLS(base func(context.Context, string, string) (net.Conn, error), res *resolver.Resolver, explicit *url.URL, targetScheme string, originTLS *tls.Config, timeout time.Duration, echMode core.ECHMode) func(context.Context, string, string, *tls.Config) (net.Conn, error) { - return func(ctx context.Context, network, address string, negotiated *tls.Config) (net.Conn, error) { - target := &url.URL{Scheme: targetScheme, Host: address} - selected, err := ProxyForURL(explicit, target) - if err != nil { - return nil, err - } - - // ECH is supported only on a direct TLS connection in this task. - // Discover the service target before dialing it; the origin name is - // retained for SNI and HTTP authority. - if echMode != core.ECHUnknown && echMode != core.ECHOff && selected == nil && targetScheme == "https" { - host, port, splitErr := net.SplitHostPort(address) - if splitErr != nil { - return nil, splitErr - } - cfg := originTLS - if negotiated != nil { - cfg = negotiated - } - connection, discoveryErr := discoverECHForConnection(ctx, res, host, port, cfg, echMode, core.HTTP2) - if discoveryErr != nil { - return nil, discoveryErr - } - if connection.configured { - got, dialErr := dialResolverWithECH(ctx, NewResolverDialer(res, timeout), DialRequest{ - Network: "tcp", Host: connection.targetHost, Port: connection.targetPort, - OriginHost: host, OriginPort: port, Candidates: connection.addresses, - }, connection.tlsConfig, echMode) - if dialErr != nil { - return nil, dialErr - } - return got.Conn, nil - } - } - - var conn net.Conn - switch { - case selected == nil: - conn, err = dialWithBudget(ctx, base, network, address, timeout, "HTTP/2 connect") - case strings.EqualFold(selected.Scheme, "socks5"), strings.EqualFold(selected.Scheme, "socks5h"): - dial := newSOCKS5Dialer(base, res, selected, strings.EqualFold(selected.Scheme, "socks5"), timeout) - conn, err = dial(ctx, network, address) - case strings.EqualFold(selected.Scheme, "http"), strings.EqualFold(selected.Scheme, "https"): - conn, err = dialHTTP2Proxy(ctx, base, selected, address, timeout) - default: - err = fmt.Errorf("unsupported proxy scheme %q", selected.Scheme) - } - if err != nil { - return nil, err - } - if targetScheme == "http" { - // H2C has no later TLS handshake. The connect deadline must not - // cover the lifetime of the HTTP/2 stream. - _ = conn.SetDeadline(time.Time{}) - return conn, nil - } - - cfg := originTLS - if negotiated != nil { - cfg = negotiated - } - if cfg == nil { - cfg = &tls.Config{} - } - cfg = cfg.Clone() - if cfg.ServerName == "" { - cfg.ServerName = target.Hostname() - } - cfg.NextProtos = []string{"h2"} - handshakeCtx, cancel := connectContext(ctx, timeout, "HTTP/2 TLS connect") - defer cancel() - tlsConn := tls.Client(conn, cfg) - if err := tlsConn.HandshakeContext(handshakeCtx); err != nil { - _ = conn.Close() - return nil, err - } - _ = tlsConn.SetDeadline(time.Time{}) - return tlsConn, nil - } -} - -func dialWithBudget(ctx context.Context, base func(context.Context, string, string) (net.Conn, error), network, address string, timeout time.Duration, phase string) (net.Conn, error) { - connectCtx, cancel := connectContext(ctx, timeout, phase) - defer cancel() - conn, err := base(connectCtx, network, address) - if err != nil { - return nil, err - } - if deadline, ok := connectCtx.Deadline(); ok { - _ = conn.SetDeadline(deadline) - } - return conn, nil -} - -func dialHTTP2Proxy(ctx context.Context, base func(context.Context, string, string) (net.Conn, error), proxy *url.URL, target string, timeout time.Duration) (net.Conn, error) { - var conn net.Conn - var err error - if strings.EqualFold(proxy.Scheme, "https") { - conn, err = newHTTPSProxyDialer(base, proxy, timeout)(ctx, "tcp", target) - } else { - conn, err = dialWithBudget(ctx, base, "tcp", canonicalProxyAddress(proxy), timeout, "HTTP proxy connect") - } - if err != nil { - return nil, err - } - if deadline, ok := ctx.Deadline(); ok { - _ = conn.SetDeadline(deadline) - } - stop := make(chan struct{}) - exited := make(chan struct{}) - go func() { - defer close(exited) - select { - case <-ctx.Done(): - _ = conn.Close() - case <-stop: - } - }() - tunneled, err := writeHTTP2CONNECT(conn, proxy, target) - close(stop) - <-exited - if err != nil { - _ = conn.Close() - return nil, err - } - return tunneled, nil -} - -func writeHTTP2CONNECT(conn net.Conn, proxy *url.URL, target string) (net.Conn, error) { - req := &http.Request{ - Method: http.MethodConnect, - URL: &url.URL{Opaque: target}, - Host: target, - Header: make(http.Header), - } - if proxy.User != nil { - password, _ := proxy.User.Password() - token := base64.StdEncoding.EncodeToString([]byte(proxy.User.Username() + ":" + password)) - req.Header.Set("Proxy-Authorization", "Basic "+token) - } - if err := req.Write(conn); err != nil { - return nil, fmt.Errorf("proxy CONNECT: %w", err) - } - reader := bufioReader(conn) - response, err := http.ReadResponse(reader, req) - if err != nil { - return nil, fmt.Errorf("proxy CONNECT response: %w", err) - } - if response.Body != nil { - _ = response.Body.Close() - } - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("proxy CONNECT returned %s", response.Status) - } - // Preserve bytes read past the CONNECT headers for the origin TLS or - // h2c preface. A proxy is not permitted to send them, but dropping them - // would make a malformed or eager proxy corrupt the tunnel. - if reader.Buffered() > 0 { - return &bufferedConn{Conn: conn, reader: reader}, nil - } - return conn, nil -} - -// bufioReader is a small adapter to keep the reader allocation at the -// protocol boundary and avoid sharing a buffered reader with the tunneled TLS -// stream. CONNECT responses end at their header boundary. -func bufioReader(conn net.Conn) *bufio.Reader { - return bufio.NewReader(conn) -} - -type bufferedConn struct { - net.Conn - reader *bufio.Reader -} - -func (c *bufferedConn) Read(p []byte) (int, error) { - return c.reader.Read(p) -} diff --git a/internal/client/http2_transport.go b/internal/client/http2_transport.go new file mode 100644 index 00000000..a8b00892 --- /dev/null +++ b/internal/client/http2_transport.go @@ -0,0 +1,281 @@ +package client + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/ryanfowler/fetch/internal/core" + "github.com/ryanfowler/fetch/internal/resolver" +) + +// getHTTP2Transport returns a standard-library transport restricted to TLS +// HTTP/2. Go 1.27 exposes HTTP/2 and h2c configuration directly on +// http.Transport, so the legacy x/net/http2.Transport is no longer needed. +func getHTTP2Transport(baseDial func(context.Context, string, string) (net.Conn, error), res *resolver.Resolver, explicitProxy *url.URL, tlsConfig *tls.Config, connectTimeout time.Duration, echMode core.ECHMode) http.RoundTripper { + rt := newHTTP2Transport(baseDial, res, explicitProxy, tlsConfig, connectTimeout, false) + rt.Protocols.SetHTTP2(true) + + if echMode != core.ECHUnknown && echMode != core.ECHOff && explicitProxy == nil { + // net/http otherwise creates the TLS config before the resolver can + // supply the origin's ECH configuration. Proxy-specific ECH wiring is + // deliberately excluded by the client's ECH policy. + rt.DialTLSContext = newECHHTTPDialTLS(baseDial, res, tlsConfig, echMode, connectTimeout, core.HTTP2) + } + + return restrictHTTP2Scheme(wrapHTTP2ProxyTransport(rt, explicitProxy), "https") +} + +// getH2CTransport returns a standard-library transport restricted to cleartext +// HTTP/2. The Protocols API is the replacement for http2.Transport.AllowHTTP. +func getH2CTransport(baseDial func(context.Context, string, string) (net.Conn, error), res *resolver.Resolver, explicitProxy *url.URL, connectTimeout time.Duration) http.RoundTripper { + rt := newHTTP2Transport(baseDial, res, explicitProxy, nil, connectTimeout, true) + rt.Protocols.SetUnencryptedHTTP2(true) + return restrictHTTP2Scheme(wrapHTTP2ProxyTransport(rt, explicitProxy), "http") +} + +func newHTTP2Transport(baseDial func(context.Context, string, string) (net.Conn, error), res *resolver.Resolver, explicitProxy *url.URL, tlsConfig *tls.Config, connectTimeout time.Duration, h2c bool) *http.Transport { + rt := &http.Transport{ + DisableCompression: true, + Protocols: &http.Protocols{}, + } + if tlsConfig != nil { + rt.TLSClientConfig = tlsConfig.Clone() + } + + proxy := ProxyFunc(explicitProxy) + transportProxy := func(req *http.Request) (*url.URL, error) { + selected, ok := selectedProxy(req.Context()) + if !ok { + var err error + selected, err = proxy(req) + if err != nil { + return nil, err + } + } + if selected == nil { + return nil, nil + } + if h2c { + // A cleartext HTTP/2 origin cannot use net/http's ordinary + // absolute-form HTTP proxy path. The dialer establishes a CONNECT + // tunnel and the transport then writes the h2c preface to it. + return nil, nil + } + switch strings.ToLower(selected.Scheme) { + case "https": + return httpsProxyAsHTTP(selected), nil + case "socks5", "socks5h": + // SOCKS destinations are carried by DialContext so socks5 can + // resolve locally and socks5h can preserve the hostname. + return nil, nil + default: + return selected, nil + } + } + + dial := wrapDialWithConnectTimeout(baseDial, connectTimeout) + if connectTimeout <= 0 { + dial = baseDial + } + if explicitProxy != nil { + switch strings.ToLower(explicitProxy.Scheme) { + case "socks5", "socks5h": + transportProxy = func(*http.Request) (*url.URL, error) { return nil, nil } + dial = newSOCKS5Dialer(baseDial, res, explicitProxy, strings.EqualFold(explicitProxy.Scheme, "socks5"), connectTimeout) + case "https": + if h2c { + transportProxy = func(*http.Request) (*url.URL, error) { return nil, nil } + dial = func(ctx context.Context, network, address string) (net.Conn, error) { + return dialH2CProxy(ctx, baseDial, explicitProxy, address, connectTimeout) + } + } else { + transportProxy = func(*http.Request) (*url.URL, error) { + return httpsProxyAsHTTP(explicitProxy), nil + } + dial = newHTTPSProxyDialer(baseDial, explicitProxy, connectTimeout) + } + case "http": + if h2c { + transportProxy = func(*http.Request) (*url.URL, error) { return nil, nil } + dial = func(ctx context.Context, network, address string) (net.Conn, error) { + return dialH2CProxy(ctx, baseDial, explicitProxy, address, connectTimeout) + } + } + } + } else { + // Environment proxy selection is request-specific. The wrapper below + // carries that selection into DialContext so concurrent requests cannot + // change one another's first hop. + wrappedDial := dial + dial = func(ctx context.Context, network, address string) (net.Conn, error) { + selected, ok := selectedProxy(ctx) + if ok && selected != nil { + switch strings.ToLower(selected.Scheme) { + case "socks5", "socks5h": + return newSOCKS5Dialer(baseDial, res, selected, strings.EqualFold(selected.Scheme, "socks5"), connectTimeout)(ctx, network, address) + case "https": + if h2c { + return dialH2CProxy(ctx, baseDial, selected, address, connectTimeout) + } + return newHTTPSProxyDialer(baseDial, selected, connectTimeout)(ctx, network, address) + case "http": + if h2c { + return dialH2CProxy(ctx, baseDial, selected, address, connectTimeout) + } + } + } + return wrappedDial(ctx, network, address) + } + } + if h2c { + // The h2c preface is application traffic. Clear any establishment + // deadline before the standard transport starts the HTTP/2 stream. + wrappedDial := dial + dial = func(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := wrappedDial(ctx, network, address) + if conn != nil { + _ = conn.SetDeadline(time.Time{}) + } + return conn, err + } + } + + rt.Proxy = transportProxy + rt.DialContext = dial + return rt +} + +func dialH2CProxy(ctx context.Context, base func(context.Context, string, string) (net.Conn, error), proxy *url.URL, target string, timeout time.Duration) (net.Conn, error) { + connectCtx, cancel := connectContext(ctx, timeout, "HTTP proxy connect") + defer cancel() + + var conn net.Conn + var err error + if strings.EqualFold(proxy.Scheme, "https") { + conn, err = newHTTPSProxyDialer(base, proxy, timeout)(ctx, "tcp", target) + } else if base != nil { + conn, err = base(connectCtx, "tcp", canonicalProxyAddress(proxy)) + } else { + var dialer net.Dialer + conn, err = dialer.DialContext(connectCtx, "tcp", canonicalProxyAddress(proxy)) + } + if err != nil { + return nil, err + } + if deadline, ok := connectCtx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + stop := make(chan struct{}) + exited := make(chan struct{}) + go func() { + defer close(exited) + select { + case <-ctx.Done(): + _ = conn.Close() + case <-stop: + } + }() + tunneled, err := writeHTTP2CONNECT(conn, proxy, target) + close(stop) + <-exited + if err != nil { + _ = conn.Close() + return nil, err + } + return tunneled, nil +} + +func writeHTTP2CONNECT(conn net.Conn, proxy *url.URL, target string) (net.Conn, error) { + req := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: target}, + Host: target, + Header: make(http.Header), + } + if proxy.User != nil { + password, _ := proxy.User.Password() + token := base64.StdEncoding.EncodeToString([]byte(proxy.User.Username() + ":" + password)) + req.Header.Set("Proxy-Authorization", "Basic "+token) + } + if err := req.Write(conn); err != nil { + return nil, fmt.Errorf("proxy CONNECT: %w", err) + } + reader := bufio.NewReader(conn) + response, err := http.ReadResponse(reader, req) + if err != nil { + return nil, fmt.Errorf("proxy CONNECT response: %w", err) + } + if response.Body != nil { + _ = response.Body.Close() + } + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("proxy CONNECT returned %s", response.Status) + } + if reader.Buffered() > 0 { + return &bufferedConn{Conn: conn, reader: reader}, nil + } + return conn, nil +} + +type bufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func wrapHTTP2ProxyTransport(rt *http.Transport, explicitProxy *url.URL) http.RoundTripper { + if explicitProxy != nil { + return rt + } + return &proxyTransport{base: rt, selectProxy: ProxyFunc(nil)} +} + +// protocolRestrictedTransport preserves the scheme restrictions of the old +// dedicated HTTP/2 transports. A standard http.Transport without HTTP/1 in +// Protocols still handles an unsupported scheme using its HTTP/1 path, which +// would silently turn an explicit HTTP/2 request into HTTP/1. +type protocolRestrictedTransport struct { + base http.RoundTripper + scheme string +} + +func restrictHTTP2Scheme(base http.RoundTripper, scheme string) http.RoundTripper { + return &protocolRestrictedTransport{base: base, scheme: scheme} +} + +func (t *protocolRestrictedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req == nil || req.URL == nil || !strings.EqualFold(req.URL.Scheme, t.scheme) { + var scheme string + if req != nil && req.URL != nil { + scheme = req.URL.Scheme + } + return nil, fmt.Errorf("http2: unsupported scheme %q", scheme) + } + return t.base.RoundTrip(req) +} + +func (t *protocolRestrictedTransport) CloseIdleConnections() { + if closer, ok := t.base.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } +} + +func (t *protocolRestrictedTransport) Close() error { + if closer, ok := t.base.(io.Closer); ok { + return closer.Close() + } + return nil +} diff --git a/internal/client/http2_transport_test.go b/internal/client/http2_transport_test.go new file mode 100644 index 00000000..de944345 --- /dev/null +++ b/internal/client/http2_transport_test.go @@ -0,0 +1,194 @@ +package client + +import ( + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/ryanfowler/fetch/internal/core" +) + +func TestHTTP2TransportUsesStandardTLSHTTP2(t *testing.T) { + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "https-http2") + })) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + + client := NewClient(ClientConfig{HTTP: core.HTTP2, Insecure: true}) + defer client.Close() + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.ProtoMajor != 2 { + t.Fatalf("response protocol = %s, want HTTP/2", resp.Proto) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != "https-http2" { + t.Fatalf("response body = %q, want https-http2", body) + } +} + +func TestH2CTransportUsesStandardUnencryptedHTTP2(t *testing.T) { + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "h2c") + })) + server.Config.Protocols = &http.Protocols{} + server.Config.Protocols.SetUnencryptedHTTP2(true) + server.Start() + defer server.Close() + + client := NewClient(ClientConfig{HTTP: core.HTTP2, H2C: true}) + defer client.Close() + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.ProtoMajor != 2 { + t.Fatalf("response protocol = %s, want HTTP/2", resp.Proto) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != "h2c" { + t.Fatalf("response body = %q, want h2c", body) + } +} + +func TestHTTP2TransportUsesHTTPProxyCONNECT(t *testing.T) { + target := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "proxied-http2") + })) + target.EnableHTTP2 = true + target.StartTLS() + defer target.Close() + + proxy := startHTTPConnectProxy(t) + defer proxy.Close() + proxyURL, err := url.Parse(proxy.URL) + if err != nil { + t.Fatal(err) + } + + client := NewClient(ClientConfig{HTTP: core.HTTP2, Insecure: true, Proxy: proxyURL}) + defer client.Close() + + req, err := http.NewRequest(http.MethodGet, target.URL, nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.ProtoMajor != 2 { + t.Fatalf("response protocol = %s, want HTTP/2", resp.Proto) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != "proxied-http2" { + t.Fatalf("response body = %q, want proxied-http2", body) + } +} + +func TestH2CTransportUsesHTTPProxyCONNECT(t *testing.T) { + target := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "proxied-h2c") + })) + target.Config.Protocols = &http.Protocols{} + target.Config.Protocols.SetUnencryptedHTTP2(true) + target.Start() + defer target.Close() + + proxy := startHTTPConnectProxy(t) + defer proxy.Close() + proxyURL, err := url.Parse(proxy.URL) + if err != nil { + t.Fatal(err) + } + + client := NewClient(ClientConfig{HTTP: core.HTTP2, H2C: true, Proxy: proxyURL}) + defer client.Close() + + req, err := http.NewRequest(http.MethodGet, target.URL, nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.ProtoMajor != 2 { + t.Fatalf("response protocol = %s, want HTTP/2", resp.Proto) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != "proxied-h2c" { + t.Fatalf("response body = %q, want proxied-h2c", body) + } +} + +func startHTTPConnectProxy(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + hijacker, ok := w.(http.Hijacker) + if !ok { + t.Error("proxy ResponseWriter does not support hijacking") + return + } + proxyConn, _, err := hijacker.Hijack() + if err != nil { + t.Errorf("proxy hijack: %v", err) + return + } + targetConn, err := net.DialTimeout("tcp", r.Host, time.Second) + if err != nil { + _, _ = io.WriteString(proxyConn, "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") + _ = proxyConn.Close() + return + } + _, _ = io.WriteString(proxyConn, "HTTP/1.1 200 Connection Established\r\n\r\n") + go func() { + _, _ = io.Copy(targetConn, proxyConn) + _ = targetConn.Close() + _ = proxyConn.Close() + }() + go func() { + _, _ = io.Copy(proxyConn, targetConn) + _ = targetConn.Close() + _ = proxyConn.Close() + }() + })) +}