mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix: Enable HTTP/2 for proxy upstream with connection tuning (#183)
* fix: Enable HTTP/2 for proxy upstream with connection tuning * fix: Handle HTTP/2 upstream translating to HTTP/1.1 downstream * fix: Remove go tool golangci-lint as per docs * fix: Code review fixes * fix: Code review fixes
This commit is contained in:
+35
-6
@@ -139,14 +139,31 @@ func newUpstreamTransport(config *ProxyConfig) *http.Transport {
|
||||
Timeout: config.ConnectTimeout,
|
||||
}
|
||||
|
||||
// Keep transport behavior close to goproxy defaults and only harden TLS:
|
||||
// enforce server certificate verification and require TLS 1.2+.
|
||||
// Proxy honours the environment (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) so
|
||||
// that PMG works in enterprise environments that require a corporate
|
||||
// upstream proxy to reach the internet.
|
||||
//
|
||||
// ForceAttemptHTTP2 is required because Go's http.Transport silently
|
||||
// disables HTTP/2 when a custom TLSClientConfig or DialContext is set.
|
||||
// Without it, every proxied request opens a separate HTTP/1.1 TCP+TLS
|
||||
// connection to the upstream registry. During npm install of large
|
||||
// projects (1000+ packages), this creates a burst of concurrent
|
||||
// connections that triggers rate-limiting (RST) from CDNs like
|
||||
// Cloudflare (which fronts registry.npmjs.org). HTTP/2 multiplexing
|
||||
// allows hundreds of requests to share a few TCP connections.
|
||||
//
|
||||
// MaxConnsPerHost caps concurrent connections per upstream host to
|
||||
// prevent overwhelming registries even if HTTP/2 is not negotiated.
|
||||
// MaxIdleConnsPerHost is raised from the default of 2 to improve
|
||||
// connection reuse.
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: dialer.DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxConnsPerHost: 100,
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 50,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: config.ConnectTimeout,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
@@ -437,6 +454,22 @@ func (ps *proxyServer) registerHandlers() {
|
||||
})
|
||||
|
||||
ps.proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
|
||||
if resp == nil {
|
||||
return resp
|
||||
}
|
||||
|
||||
// When the upstream transport negotiates HTTP/2, responses arrive with
|
||||
// Proto "HTTP/2.0" and ProtoMajor 2. goproxy writes MITM responses via
|
||||
// resp.Write(), which serialises the status line verbatim. An HTTP/1.1
|
||||
// client (pip, npm, etc.) rejects the "HTTP/2.0 200 OK" status line and
|
||||
// resets the connection. Normalise to HTTP/1.1 so the response is valid
|
||||
// for the downstream MITM connection.
|
||||
if resp.ProtoMajor != 1 {
|
||||
resp.Proto = "HTTP/1.1"
|
||||
resp.ProtoMajor = 1
|
||||
resp.ProtoMinor = 1
|
||||
}
|
||||
|
||||
reqCtx, err := newRequestContext(ctx.Req)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create request context: %v", err)
|
||||
@@ -445,10 +478,6 @@ func (ps *proxyServer) registerHandlers() {
|
||||
|
||||
log.Debugf("[%s] Response received for %s", reqCtx.RequestID, ctx.Req.URL.String())
|
||||
|
||||
if resp == nil {
|
||||
return resp
|
||||
}
|
||||
|
||||
modifier, ok := ctx.UserData.(ResponseModifierFunc)
|
||||
if !ok || modifier == nil {
|
||||
return resp
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewProxyServerSecuresUpstreamTLSConfig(t *testing.T) {
|
||||
@@ -28,6 +29,25 @@ func TestNewProxyServerSecuresUpstreamTLSConfig(t *testing.T) {
|
||||
assert.GreaterOrEqual(t, internalProxy.proxy.Tr.TLSClientConfig.MinVersion, uint16(tls.VersionTLS12), "minimum TLS version should be 1.2+")
|
||||
}
|
||||
|
||||
func TestNewProxyServerUpstreamTransportEnablesHTTP2(t *testing.T) {
|
||||
server, err := NewProxyServer(&ProxyConfig{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
EnableMITM: false,
|
||||
ConnectTimeout: 30 * time.Second,
|
||||
RequestTimeout: 5 * time.Minute,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
internalProxy, ok := server.(*proxyServer)
|
||||
assert.True(t, ok)
|
||||
|
||||
tr := internalProxy.proxy.Tr
|
||||
assert.True(t, tr.ForceAttemptHTTP2, "HTTP/2 must be enabled to allow upstream connection multiplexing")
|
||||
assert.Equal(t, 100, tr.MaxConnsPerHost, "MaxConnsPerHost should cap concurrent connections per upstream host")
|
||||
assert.Equal(t, 50, tr.MaxIdleConnsPerHost, "MaxIdleConnsPerHost should be raised from default of 2 for connection reuse")
|
||||
assert.Equal(t, 200, tr.MaxIdleConns, "MaxIdleConns should accommodate multiple upstream registries")
|
||||
}
|
||||
|
||||
func TestNormalizeRequestURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -130,3 +150,49 @@ func TestNewProxyServerRejectsUntrustedUpstreamCertByDefault(t *testing.T) {
|
||||
assert.Error(t, err, "untrusted upstream certificate should fail verification")
|
||||
assert.Nil(t, resp)
|
||||
}
|
||||
|
||||
func TestResponseProtoNormalisedToHTTP11(t *testing.T) {
|
||||
// When the upstream transport negotiates HTTP/2, responses arrive with
|
||||
// Proto "HTTP/2.0". goproxy writes MITM responses via resp.Write() which
|
||||
// serialises the status line verbatim. An HTTP/1.1 client rejects the
|
||||
// "HTTP/2.0 200 OK" status line and resets the connection.
|
||||
// The OnResponse handler must normalise the proto back to HTTP/1.1.
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
server, err := NewProxyServer(&ProxyConfig{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
EnableMITM: false,
|
||||
ConnectTimeout: 5 * time.Second,
|
||||
RequestTimeout: 5 * time.Second,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
ps, ok := server.(*proxyServer)
|
||||
assert.True(t, ok)
|
||||
|
||||
// Simulate an HTTP/2 response going through the OnResponse handler by
|
||||
// sending a request through the proxy to the upstream test server.
|
||||
assert.NoError(t, ps.Start())
|
||||
defer func() {
|
||||
_ = ps.Stop(t.Context())
|
||||
}()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{
|
||||
Scheme: "http",
|
||||
Host: ps.Address(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(upstream.URL)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
assert.Equal(t, 1, resp.ProtoMajor, "response ProtoMajor should be 1 (HTTP/1.1)")
|
||||
assert.Equal(t, 1, resp.ProtoMinor, "response ProtoMinor should be 1 (HTTP/1.1)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user