diff --git a/util/network/proxyprovider/provider_linux.go b/util/network/proxyprovider/provider_linux.go index 926c29e10..0d64382fc 100644 --- a/util/network/proxyprovider/provider_linux.go +++ b/util/network/proxyprovider/provider_linux.go @@ -81,10 +81,7 @@ func New(opt Opt) (network.ProxyProvider, error) { lru: list.New(), egressProviders: maps.Clone(opt.EgressProviders), ownedEgressProviders: slices.Clone(opt.OwnedEgressProviders), - transport: &http.Transport{ - Proxy: nil, - DisableCompression: true, - }, + transport: newProxyTransport(), } p.pool = netpool.New(netpool.Opt[*proxyNS]{ Name: "proxy network namespace", @@ -98,6 +95,14 @@ func New(opt Opt) (network.ProxyProvider, error) { return p, nil } +func newProxyTransport() *http.Transport { + return &http.Transport{ + Proxy: nil, + DisableCompression: true, + ForceAttemptHTTP2: true, + } +} + type provider struct { root string next atomic.Uint32 @@ -586,13 +591,7 @@ func (h *proxyHandler) handleConnect(w http.ResponseWriter, r *http.Request) { return } h.recordRequest(req, resp.StatusCode, finalURL(req, resp)) - // Response.Write uses resp.Proto for the status line. In the MITM - // path, resp describes the upstream fetch, so align it to the - // client-facing request we intercepted. - resp.Proto = req.Proto - resp.ProtoMajor = req.ProtoMajor - resp.ProtoMinor = req.ProtoMinor - resp.Close = resp.Close || req.Close || !req.ProtoAtLeast(1, 1) + prepareMITMResponse(req, resp) tracker := newProxyBodyTracker(resp.Body) resp.Body = tracker if err := resp.Write(tlsConn); err != nil { @@ -608,6 +607,23 @@ func (h *proxyHandler) handleConnect(w http.ResponseWriter, r *http.Request) { } } +func prepareMITMResponse(req *http.Request, resp *http.Response) { + // Response.Write uses resp.Proto for the status line. In the MITM + // path, resp describes the upstream fetch, so align it to the + // client-facing request we intercepted. + resp.Proto = req.Proto + resp.ProtoMajor = req.ProtoMajor + resp.ProtoMinor = req.ProtoMinor + resp.Close = resp.Close || req.Close || !req.ProtoAtLeast(1, 1) + if resp.ContentLength < 0 && len(resp.TransferEncoding) == 0 { + // Response.Write marks its internal response clone as close-delimited + // for this case, but it does not update resp.Close. Mirror that here + // so handleConnect closes the client-facing TLS connection after the + // upstream body is copied. + resp.Close = true + } +} + type proxyBodyTracker struct { body io.ReadCloser hash hash.Hash diff --git a/util/network/proxyprovider/provider_linux_test.go b/util/network/proxyprovider/provider_linux_test.go index 4faef9e47..d0eb8dc9b 100644 --- a/util/network/proxyprovider/provider_linux_test.go +++ b/util/network/proxyprovider/provider_linux_test.go @@ -98,6 +98,36 @@ func TestProxyHandlerRoundTripIgnoresClientContextCancel(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode) } +func TestNewProxyTransportAttemptsHTTP2(t *testing.T) { + tr := newProxyTransport() + t.Cleanup(tr.CloseIdleConnections) + + // The provider transport is cloned per namespace and given a custom + // DialContext. Without ForceAttemptHTTP2, the clone can advertise h2 via + // ALPN without a registered HTTP/2 RoundTripper, causing apk update to fail + // against Alpine HTTPS repositories with proxy 502 responses. + require.True(t, tr.ForceAttemptHTTP2) +} + +func TestPrepareMITMResponseClosesUnknownLength(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://www.example.com/", nil) + resp := &http.Response{ + Proto: "HTTP/2.0", + ProtoMajor: 2, + ProtoMinor: 0, + StatusCode: http.StatusOK, + Status: "200 OK", + ContentLength: -1, + } + + prepareMITMResponse(req, resp) + + require.Equal(t, req.Proto, resp.Proto) + require.Equal(t, req.ProtoMajor, resp.ProtoMajor) + require.Equal(t, req.ProtoMinor, resp.ProtoMinor) + require.True(t, resp.Close) +} + func TestProxyHandlerMarksPostIncomplete(t *testing.T) { methodCh := make(chan string, 1) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {