proxyprovider: fix exec proxy HTTPS regressions

Commit 91cc422d5 split exec proxying from exec network mode and
started cloning the proxy transport per egress namespace with a custom
DialContext.

Without ForceAttemptHTTP2, the cloned transport could advertise h2 via
ALPN without a registered HTTP/2 RoundTripper, causing Alpine apk update
requests to fail with proxy 502 responses.

Restoring HTTP/2 upstream also exposes unknown-length HTTP/2 responses.
When those responses are rewritten for the MITM HTTP/1.1 client, close
the client-facing connection so clients can delimit the response body.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2026-06-10 22:32:37 -07:00
parent e7b395c2e1
commit 19bcaabb39
2 changed files with 57 additions and 11 deletions

View File

@@ -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

View File

@@ -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) {