From 8ab34fcf8ba693de2f504154a2fa24e003fa8e17 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Wed, 30 Sep 2026 11:05:45 +0200 Subject: [PATCH] [proxy] Apply the upstream HTTP version before cloning transports (#7806) createClientEntry and NewMultiTransport clone the secure transport into its insecure variant before newUpstreamTransport applies the configured HTTP version. http.Transport.Clone runs the source's one-time protocol setup, and at that point ForceAttemptHTTP2 is still false while a custom DialContext is set, so net/http disables HTTP/2 on the source for good. Setting ForceAttemptHTTP2 afterwards has no effect. As a result every TLS-verified upstream has been served over HTTP/1.1 since the upstream HTTP version became configurable, whatever NB_PROXY_UPSTREAM_HTTP_VERSION says, while skip-TLS-verify upstreams kept HTTP/2. gRPC upstreams break outright: unary calls get a 502 and streaming calls hang until the client gives up. Apply the version to the base transport before cloning it, and add a test that the direct and insecure transports both offer h2. --- proxy/internal/roundtrip/clone_http2_test.go | 40 ++++++++++++++++++++ proxy/internal/roundtrip/multi.go | 3 ++ proxy/internal/roundtrip/netbird.go | 3 ++ 3 files changed, 46 insertions(+) create mode 100644 proxy/internal/roundtrip/clone_http2_test.go diff --git a/proxy/internal/roundtrip/clone_http2_test.go b/proxy/internal/roundtrip/clone_http2_test.go new file mode 100644 index 000000000..f2707ff19 --- /dev/null +++ b/proxy/internal/roundtrip/clone_http2_test.go @@ -0,0 +1,40 @@ +package roundtrip + +import ( + "net/http" + "testing" + + log "github.com/sirupsen/logrus" +) + +// offersHTTP2 reports whether t will negotiate h2 with a TLS upstream. +// Clone forces t's one-time protocol setup, which registers an "h2" +// handler in t.TLSNextProto only when HTTP/2 ended up enabled. +func offersHTTP2(t *http.Transport) bool { + _ = t.Clone() + _, ok := t.TLSNextProto["h2"] + return ok +} + +func TestNewMultiTransportKeepsHTTP2AcrossClone(t *testing.T) { + for _, tc := range []struct { + version string + want bool + }{ + {string(upstreamHTTPAuto), true}, + {string(upstreamHTTP2), true}, + {string(upstreamHTTP11), false}, + } { + t.Run(tc.version, func(t *testing.T) { + t.Setenv(EnvUpstreamHTTPVersion, tc.version) + m := NewMultiTransport(noEmbeddedRoundTripper{}, log.New()) + + if got := offersHTTP2(m.direct.primary); got != tc.want { + t.Errorf("direct transport offers h2 = %v, want %v", got, tc.want) + } + if got := offersHTTP2(m.insecure.primary); got != tc.want { + t.Errorf("insecure transport offers h2 = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/proxy/internal/roundtrip/multi.go b/proxy/internal/roundtrip/multi.go index 1abf54a8d..d50ad1fc9 100644 --- a/proxy/internal/roundtrip/multi.go +++ b/proxy/internal/roundtrip/multi.go @@ -64,6 +64,9 @@ func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTra ReadBufferSize: cfg.readBufferSize, DisableCompression: cfg.disableCompression, } + // Clone runs the transport's one-time protocol setup, so the HTTP + // version must be applied first or the source loses HTTP/2 for good. + applyUpstreamHTTPVersion(direct, cfg.upstreamHTTPVersion) insecure := direct.Clone() insecure.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // matches the embedded NetBird transport's per-target opt-in diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index d7b464182..07b497c46 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -425,6 +425,9 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account ReadBufferSize: n.transportCfg.readBufferSize, DisableCompression: n.transportCfg.disableCompression, } + // Clone runs the transport's one-time protocol setup, so the HTTP + // version must be applied first or the source loses HTTP/2 for good. + applyUpstreamHTTPVersion(transport, n.transportCfg.upstreamHTTPVersion) insecureTransport := transport.Clone() insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec