From 17353f3d8ec8921709dd2ad9193c8427f6e15158 Mon Sep 17 00:00:00 2001 From: Guthrie McAfee Armstrong Date: Tue, 28 Jul 2026 17:33:30 -0400 Subject: [PATCH 1/2] network: chain proxy requests via upstream proxy Use proxy settings from the `buildkitd` environment to route requests from BuildKit's internal proxy through the configured upstream proxy. Signed-off-by: Guthrie McAfee Armstrong --- docs/proxy.md | 22 +- executor/containerdexecutor/executor.go | 2 +- executor/env_test.go | 30 +++ executor/executor.go | 19 ++ executor/runcexecutor/executor.go | 2 +- util/network/proxyprovider/provider_linux.go | 15 +- .../proxyprovider/provider_linux_test.go | 122 +++++++++- util/network/proxyprovider/upstream.go | 177 ++++++++++++++ util/network/proxyprovider/upstream_test.go | 224 ++++++++++++++++++ 9 files changed, 604 insertions(+), 9 deletions(-) create mode 100644 executor/env_test.go create mode 100644 util/network/proxyprovider/upstream.go create mode 100644 util/network/proxyprovider/upstream_test.go diff --git a/docs/proxy.md b/docs/proxy.md index 932db2889fae..5c89742e5915 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -76,8 +76,10 @@ process: ```text HTTP_PROXY HTTPS_PROXY +ALL_PROXY http_proxy https_proxy +all_proxy NO_PROXY no_proxy ``` @@ -91,6 +93,20 @@ BuildKit also injects a generated CA certificate into common Linux trust bundle locations for the duration of the exec. This lets HTTPS requests using the system trust store pass through the BuildKit proxy. +## Upstream proxies + +The BuildKit proxy can chain requests through an upstream proxy using Go's +standard proxy environment handling, including HTTP(S) and SOCKS5 proxy URLs. + +The internal proxy inherits `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` from +the `buildkitd` environment. These settings apply to all proxy-network execs; +proxy settings passed to an individual exec do not change upstream routing. +The proxy URL values injected into each process still point to BuildKit's +internal proxy. `NO_PROXY` applies to the connection from the BuildKit proxy to +the destination, so matching requests connect directly. An invalid upstream +proxy URL prevents proxy-network execs from starting rather than falling back +to a direct connection. + ## Request capture and logs The proxy records network requests made by exec steps. Build output includes a @@ -126,6 +142,6 @@ The current implementation is Linux-focused. Rootless workers also have the usual rootless networking limitations, where worker networking may behave like host networking. -Applications that ignore `HTTP_PROXY` and `HTTPS_PROXY`, use custom trust -stores, or open raw TCP connections cannot bypass the proxy. That traffic is -blocked instead of being captured. +Applications that ignore the injected proxy environment variables, use custom +trust stores, or open raw TCP connections cannot bypass the proxy. That traffic +is blocked instead of being captured. diff --git a/executor/containerdexecutor/executor.go b/executor/containerdexecutor/executor.go index d87a31126242..597817eecf6e 100644 --- a/executor/containerdexecutor/executor.go +++ b/executor/containerdexecutor/executor.go @@ -192,7 +192,7 @@ func (w *containerdExecutor) Run(ctx context.Context, id string, root executor.M } defer namespace.Close() if proxyNS, ok := namespace.(network.ProxyNamespace); ok { - meta.Env = append(meta.Env, proxyNS.ProxyEnv()...) + meta.Env = executor.ReplaceEnv(meta.Env, proxyNS.ProxyEnv()) cleanProxyCA, err := executor.InjectProxyCA(details.rootfsPath, proxyNS.ProxyCACert()) if err != nil { return nil, err diff --git a/executor/env_test.go b/executor/env_test.go new file mode 100644 index 000000000000..45c6e6f51ed1 --- /dev/null +++ b/executor/env_test.go @@ -0,0 +1,30 @@ +package executor + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReplaceEnv(t *testing.T) { + env := []string{ + "FOO=one", + "HTTP_PROXY=http://upstream.example", + "http_proxy=http://upstream.example", + "NO_PROXY=example.com", + "BAR=two", + } + replacement := []string{ + "HTTP_PROXY=http://buildkit-proxy", + "http_proxy=http://buildkit-proxy", + "NO_PROXY=localhost", + } + + require.Equal(t, []string{ + "FOO=one", + "BAR=two", + "HTTP_PROXY=http://buildkit-proxy", + "http_proxy=http://buildkit-proxy", + "NO_PROXY=localhost", + }, ReplaceEnv(env, replacement)) +} diff --git a/executor/executor.go b/executor/executor.go index 999e248396a7..ef040ac1d119 100644 --- a/executor/executor.go +++ b/executor/executor.go @@ -4,6 +4,7 @@ import ( "context" "io" "net" + "strings" "syscall" "github.com/containerd/containerd/v2/core/mount" @@ -34,6 +35,24 @@ type Meta struct { RemoveMountStubsRecursive bool } +// ReplaceEnv removes entries whose names are present in replacement, then +// appends replacement in order. +func ReplaceEnv(env, replacement []string) []string { + names := make(map[string]struct{}, len(replacement)) + for _, entry := range replacement { + name, _, _ := strings.Cut(entry, "=") + names[name] = struct{}{} + } + out := make([]string, 0, len(env)+len(replacement)) + for _, entry := range env { + name, _, _ := strings.Cut(entry, "=") + if _, ok := names[name]; !ok { + out = append(out, entry) + } + } + return append(out, replacement...) +} + type MountableRef interface { Mount() ([]mount.Mount, func() error, error) IdentityMapping() *user.IdentityMapping diff --git a/executor/runcexecutor/executor.go b/executor/runcexecutor/executor.go index a301fecb294e..0f187e6882bb 100644 --- a/executor/runcexecutor/executor.go +++ b/executor/runcexecutor/executor.go @@ -213,7 +213,7 @@ func (w *runcExecutor) Run(ctx context.Context, id string, root executor.Mount, return nil, err } if proxyNS, ok := namespace.(network.ProxyNamespace); ok { - meta.Env = append(meta.Env, proxyNS.ProxyEnv()...) + meta.Env = executor.ReplaceEnv(meta.Env, proxyNS.ProxyEnv()) } doReleaseNetwork := true defer func() { diff --git a/util/network/proxyprovider/provider_linux.go b/util/network/proxyprovider/provider_linux.go index 0d64382fcdbe..822d331a0074 100644 --- a/util/network/proxyprovider/provider_linux.go +++ b/util/network/proxyprovider/provider_linux.go @@ -281,8 +281,10 @@ func (n *proxyNS) ProxyEnv() []string { return []string{ "HTTP_PROXY=" + proxy, "HTTPS_PROXY=" + proxy, + "ALL_PROXY=" + proxy, "http_proxy=" + proxy, "https_proxy=" + proxy, + "all_proxy=" + proxy, "NO_PROXY=" + noProxy, "no_proxy=" + noProxy, } @@ -381,6 +383,10 @@ func (n *proxyNS) startProxy(ctx context.Context, proxy *network.ProxyConfig) er if proxy == nil { return errors.New("proxy network config is required") } + hasHTTPSProxy, err := upstreamProxyEnvironment() + if err != nil { + return err + } ln, err := listenInNetNS(ctx, n.proxyNSPath, "tcp4", net.JoinHostPort(n.hostIP.String(), "0")) if err != nil { return errors.WithStack(err) @@ -402,12 +408,17 @@ func (n *proxyNS) startProxy(ctx context.Context, proxy *network.ProxyConfig) er n.egressNS = egressNS transport := n.provider.transport.Clone() transport.DialContext = dialer.DialContext + transport.Proxy = http.ProxyFromEnvironment + var roundTripper http.RoundTripper = transport + if hasHTTPSProxy { + roundTripper = configureTransportForUpstream(transport) + } n.transport = transport handler := &proxyHandler{ provider: n.provider, policy: proxy.Policy, capture: proxy.Capture, - transport: transport, + transport: roundTripper, } n.server = &http.Server{ Handler: handler, @@ -503,7 +514,7 @@ type proxyHandler struct { provider *provider policy network.ProxyPolicy capture *network.ProxyCapture - transport *http.Transport + transport http.RoundTripper } func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/util/network/proxyprovider/provider_linux_test.go b/util/network/proxyprovider/provider_linux_test.go index 0fe8e0d7eb4c..07d32f65c5f8 100644 --- a/util/network/proxyprovider/provider_linux_test.go +++ b/util/network/proxyprovider/provider_linux_test.go @@ -7,9 +7,12 @@ import ( "container/list" "context" "crypto/x509" + "encoding/base64" + "io" "net" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -22,6 +25,120 @@ import ( "github.com/stretchr/testify/require" ) +func TestProxyHandlerUsesUpstreamHTTPProxy(t *testing.T) { + requestCh := make(chan *http.Request, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCh <- r.Clone(r.Context()) + _, _ = w.Write([]byte("from upstream proxy")) + })) + t.Cleanup(upstream.Close) + + handler := newTestProxyHandler(t, nil) + transport := handler.transport.(*http.Transport) + proxyURL, err := url.Parse(upstream.URL) + require.NoError(t, err) + transport.Proxy = http.ProxyURL(proxyURL) + resp := httptest.NewRecorder() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://destination.invalid/file", nil) + + handler.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + require.Equal(t, "from upstream proxy", resp.Body.String()) + proxied := <-requestCh + require.Equal(t, "destination.invalid", proxied.Host) + require.Equal(t, "http://destination.invalid/file", proxied.URL.String()) +} + +func TestProxyHandlerUsesUpstreamProxyForHTTPS(t *testing.T) { + type connectRequest struct { + host string + auth string + } + + originRequestCh := make(chan *http.Request, 1) + origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + originRequestCh <- r.Clone(r.Context()) + w.Header().Set("Connection", "close") + _, _ = w.Write([]byte("from TLS origin")) + })) + t.Cleanup(origin.Close) + + connectCh := make(chan connectRequest, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + http.Error(w, "CONNECT required", http.StatusMethodNotAllowed) + return + } + connectCh <- connectRequest{ + host: r.Host, + auth: r.Header.Get("Proxy-Authorization"), + } + originConn, err := net.Dial("tcp", origin.Listener.Addr().String()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + clientConn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + _ = originConn.Close() + return + } + _, _ = io.WriteString(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n") + go func() { + _, _ = io.Copy(originConn, clientConn) + _ = originConn.Close() + }() + _, _ = io.Copy(clientConn, originConn) + _ = clientConn.Close() + })) + t.Cleanup(upstream.Close) + + _, originPort, err := net.SplitHostPort(origin.Listener.Addr().String()) + require.NoError(t, err) + targetURL := "https://example.com:" + originPort + "/file" + proxyURL := strings.Replace(upstream.URL, "http://", "http://proxy-user:proxy-pass@", 1) + handler := newTestProxyHandler(t, nil) + transport := handler.transport.(*http.Transport) + parsedProxyURL, err := url.Parse(proxyURL) + require.NoError(t, err) + transport.Proxy = http.ProxyURL(parsedProxyURL) + pool := x509.NewCertPool() + pool.AddCert(origin.Certificate()) + transport.TLSClientConfig.RootCAs = pool + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, targetURL, nil) + require.NoError(t, err) + req.Close = true + req.Header.Set("Proxy-Authorization", "Basic client-supplied") + + resp, err := handler.roundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "from TLS origin", string(body)) + + connect := <-connectCh + require.Equal(t, "example.com:"+originPort, connect.host) + expectedAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("proxy-user:proxy-pass")) + require.Equal(t, expectedAuth, connect.auth) + originRequest := <-originRequestCh + require.Equal(t, http.MethodGet, originRequest.Method) + require.Equal(t, "/file", originRequest.URL.Path) + require.Empty(t, originRequest.Header.Get("Proxy-Authorization")) +} + +func TestProxyNamespaceEnvIncludesAllProxy(t *testing.T) { + ln, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + ns := proxyNS{ln: ln} + proxy := "http://" + ln.Addr().String() + require.Contains(t, ns.ProxyEnv(), "ALL_PROXY="+proxy) + require.Contains(t, ns.ProxyEnv(), "all_proxy="+proxy) +} + func TestProxyHandlerCapturesGetMaterial(t *testing.T) { methodCh := make(chan string, 1) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -86,8 +203,9 @@ func TestProxyHandlerRoundTripIgnoresClientContextCancel(t *testing.T) { pool := x509.NewCertPool() pool.AddCert(upstream.Certificate()) handler := newTestProxyHandler(t, nil) - handler.transport.TLSClientConfig = upstream.Client().Transport.(*http.Transport).TLSClientConfig.Clone() - handler.transport.TLSClientConfig.RootCAs = pool + transport := handler.transport.(*http.Transport) + transport.TLSClientConfig = upstream.Client().Transport.(*http.Transport).TLSClientConfig.Clone() + transport.TLSClientConfig.RootCAs = pool ctx, cancel := context.WithCancelCause(t.Context()) cancel(context.Canceled) diff --git a/util/network/proxyprovider/upstream.go b/util/network/proxyprovider/upstream.go new file mode 100644 index 000000000000..b40bfa320dc6 --- /dev/null +++ b/util/network/proxyprovider/upstream.go @@ -0,0 +1,177 @@ +package proxyprovider + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "unicode/utf8" + + "golang.org/x/net/idna" +) + +func upstreamProxyEnvironment() (bool, error) { + var hasHTTPSProxy bool + for _, names := range [][2]string{ + {"HTTP_PROXY", "http_proxy"}, + {"HTTPS_PROXY", "https_proxy"}, + } { + name, value := proxyEnvironmentValue(names[0], names[1]) + if value == "" { + continue + } + proxyURL, err := parseProxyEnvironmentValue(value) + if err != nil { + // Do not include value or err because either may contain proxy credentials. + return false, fmt.Errorf("invalid %s", name) + } + if strings.EqualFold(proxyURL.Scheme, "https") { + hasHTTPSProxy = true + } + } + return hasHTTPSProxy, nil +} + +func proxyEnvironmentValue(names ...string) (string, string) { + for _, name := range names { + if value := os.Getenv(name); value != "" { + return name, value + } + } + return "", "" +} + +// parseProxyEnvironmentValue matches the URL handling used by +// http.ProxyFromEnvironment, including treating host[:port] as an HTTP proxy. +func parseProxyEnvironmentValue(value string) (*url.URL, error) { + proxyURL, err := url.Parse(value) + if err != nil || proxyURL.Scheme == "" || proxyURL.Host == "" { + proxyURL, err = url.Parse("http://" + value) + } + if err != nil { + return nil, err + } + if proxyURL.Hostname() == "" { + return nil, fmt.Errorf("proxy URL with scheme %q is missing host", proxyURL.Scheme) + } + switch proxyURL.Scheme { + case "http", "https", "socks5", "socks5h": + default: + return nil, fmt.Errorf("unsupported proxy URL scheme %q", proxyURL.Scheme) + } + return proxyURL, nil +} + +func canonicalProxyAddr(proxyURL *url.URL) string { + host := proxyURL.Hostname() + if strings.IndexFunc(host, func(r rune) bool { return r >= utf8.RuneSelf }) >= 0 { + if asciiHost, err := idna.Lookup.ToASCII(host); err == nil { + host = asciiHost + } + } + port := proxyURL.Port() + if port == "" { + if strings.EqualFold(proxyURL.Scheme, "https") { + port = "443" + } else { + port = "80" + } + } + return net.JoinHostPort(host, port) +} + +type upstreamProxySelectionKey struct{} + +type upstreamProxySelection struct { + once sync.Once + proxyURL *url.URL + err error + httpsAddr string +} + +func (s *upstreamProxySelection) proxyForRequest(req *http.Request, proxyFunc func(*http.Request) (*url.URL, error)) (*url.URL, error) { + // Transport may retry a request while a dial from an earlier attempt is still + // running. Pin the selection so surviving dials read only immutable + // state and retries use the same policy decision. + s.once.Do(func() { + s.proxyURL, s.err = proxyFunc(req) + if s.err == nil && s.proxyURL != nil && strings.EqualFold(s.proxyURL.Scheme, "https") { + s.httpsAddr = canonicalProxyAddr(s.proxyURL) + } + }) + return s.proxyURL, s.err +} + +type upstreamProxyRoundTripper struct { + transport *http.Transport +} + +func (t *upstreamProxyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + selection := &upstreamProxySelection{} + ctx := context.WithValue(req.Context(), upstreamProxySelectionKey{}, selection) + return t.transport.RoundTrip(req.WithContext(ctx)) +} + +// configureTransportForUpstream disables HTTP/2 negotiation with an HTTPS +// proxy. Direct and tunneled origin connections retain HTTP/2 support. net/http +// sends CONNECT to forward proxies using HTTP/1.1 and does not support HTTP/2 +// proxy connections. +func configureTransportForUpstream(transport *http.Transport) http.RoundTripper { + proxyFunc := transport.Proxy + if proxyFunc == nil { + return transport + } + transport.Proxy = func(req *http.Request) (*url.URL, error) { + selection, _ := req.Context().Value(upstreamProxySelectionKey{}).(*upstreamProxySelection) + if selection == nil { + return proxyFunc(req) + } + return selection.proxyForRequest(req, proxyFunc) + } + dialContext := transport.DialContext + if dialContext == nil { + dialer := &net.Dialer{} + dialContext = dialer.DialContext + } + transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dialContext(ctx, network, addr) + if err != nil { + return nil, err + } + tlsConfig := transport.TLSClientConfig + if tlsConfig == nil { + tlsConfig = &tls.Config{} + } else { + tlsConfig = tlsConfig.Clone() + } + if tlsConfig.ServerName == "" { + tlsConfig.ServerName, _, err = net.SplitHostPort(addr) + if err != nil { + _ = conn.Close() + return nil, err + } + } + selection, _ := ctx.Value(upstreamProxySelectionKey{}).(*upstreamProxySelection) + if selection != nil && selection.httpsAddr == addr { + tlsConfig.NextProtos = []string{"http/1.1"} + } + tlsConn := tls.Client(conn, tlsConfig) + handshakeCtx := ctx + if transport.TLSHandshakeTimeout != 0 { + var cancel context.CancelFunc + handshakeCtx, cancel = context.WithTimeout(ctx, transport.TLSHandshakeTimeout) + defer cancel() + } + if err := tlsConn.HandshakeContext(handshakeCtx); err != nil { + _ = conn.Close() + return nil, err + } + return tlsConn, nil + } + return &upstreamProxyRoundTripper{transport: transport} +} diff --git a/util/network/proxyprovider/upstream_test.go b/util/network/proxyprovider/upstream_test.go new file mode 100644 index 000000000000..591f0c9c61e7 --- /dev/null +++ b/util/network/proxyprovider/upstream_test.go @@ -0,0 +1,224 @@ +package proxyprovider + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpstreamProxyEnvironment(t *testing.T) { + for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} { + t.Setenv(name, "") + } + hasHTTPSProxy, err := upstreamProxyEnvironment() + require.NoError(t, err) + require.False(t, hasHTTPSProxy) + + t.Setenv("HTTPS_PROXY", "http://proxy.example:3128") + hasHTTPSProxy, err = upstreamProxyEnvironment() + require.NoError(t, err) + require.False(t, hasHTTPSProxy) + + t.Setenv("HTTPS_PROXY", "") + t.Setenv("https_proxy", "https://proxy.example:443") + hasHTTPSProxy, err = upstreamProxyEnvironment() + require.NoError(t, err) + require.True(t, hasHTTPSProxy) + + // The uppercase value takes precedence, matching http.ProxyFromEnvironment. + t.Setenv("HTTPS_PROXY", "http://proxy.example:3128") + hasHTTPSProxy, err = upstreamProxyEnvironment() + require.NoError(t, err) + require.False(t, hasHTTPSProxy) +} + +func TestParseProxyEnvironmentValue(t *testing.T) { + for _, tc := range []struct { + value string + wantScheme string + wantHost string + }{ + {value: "proxy.example:3128", wantScheme: "http", wantHost: "proxy.example:3128"}, + {value: "http://proxy.example:3128", wantScheme: "http", wantHost: "proxy.example:3128"}, + {value: "https://proxy.example", wantScheme: "https", wantHost: "proxy.example"}, + {value: "socks5://proxy.example:1080", wantScheme: "socks5", wantHost: "proxy.example:1080"}, + {value: "socks5h://proxy.example:1080", wantScheme: "socks5h", wantHost: "proxy.example:1080"}, + } { + t.Run(tc.value, func(t *testing.T) { + proxyURL, err := parseProxyEnvironmentValue(tc.value) + require.NoError(t, err) + require.Equal(t, tc.wantScheme, proxyURL.Scheme) + require.Equal(t, tc.wantHost, proxyURL.Host) + }) + } +} + +func TestUpstreamProxyEnvironmentRejectsInvalidURL(t *testing.T) { + for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} { + t.Setenv(name, "") + } + + t.Setenv("HTTP_PROXY", "http://user:secret@proxy.example/%zz") + _, err := upstreamProxyEnvironment() + require.EqualError(t, err, "invalid HTTP_PROXY") + require.NotContains(t, err.Error(), "secret") + + t.Setenv("HTTP_PROXY", "") + t.Setenv("https_proxy", "http://user:other-secret@proxy.example/%zz") + _, err = upstreamProxyEnvironment() + require.EqualError(t, err, "invalid https_proxy") + require.NotContains(t, err.Error(), "other-secret") + + t.Setenv("https_proxy", "") + t.Setenv("HTTP_PROXY", "/") + _, err = upstreamProxyEnvironment() + require.EqualError(t, err, "invalid HTTP_PROXY") + + t.Setenv("HTTP_PROXY", "http://:80") + _, err = upstreamProxyEnvironment() + require.EqualError(t, err, "invalid HTTP_PROXY") + + t.Setenv("HTTP_PROXY", "ftp://user:scheme-secret@proxy.example:21") + _, err = upstreamProxyEnvironment() + require.EqualError(t, err, "invalid HTTP_PROXY") + require.NotContains(t, err.Error(), "scheme-secret") +} + +func TestHTTPSUpstreamProxySelectionIsStableAcrossRetries(t *testing.T) { + proxyURL, err := url.Parse("https://proxy.example:443") + require.NoError(t, err) + + var calls int + transport := &http.Transport{ + Proxy: func(*http.Request) (*url.URL, error) { + calls++ + if calls > 1 { + return nil, errors.New("proxy selection changed on retry") + } + return proxyURL, nil + }, + } + roundTripper := configureTransportForUpstream(transport) + _, ok := roundTripper.(*upstreamProxyRoundTripper) + require.True(t, ok) + + selection := &upstreamProxySelection{} + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://destination.example", nil) + require.NoError(t, err) + req = req.WithContext(context.WithValue(req.Context(), upstreamProxySelectionKey{}, selection)) + + first, err := transport.Proxy(req) + require.NoError(t, err) + second, err := transport.Proxy(req) + require.NoError(t, err) + require.Equal(t, proxyURL, first) + require.Equal(t, proxyURL, second) + require.Equal(t, 1, calls) + require.Equal(t, "proxy.example:443", selection.httpsAddr) +} + +func TestHTTPSUpstreamProxyUsesHTTP1(t *testing.T) { + originProtocol := make(chan string, 1) + originServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + originProtocol <- r.Proto + w.WriteHeader(http.StatusNoContent) + })) + originServer.EnableHTTP2 = true + originServer.StartTLS() + t.Cleanup(originServer.Close) + + type proxyRequest struct { + method string + protocol string + } + proxyRequests := make(chan proxyRequest, 1) + proxyServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyRequests <- proxyRequest{method: r.Method, protocol: r.Proto} + upstreamConn, err := net.Dial("tcp", originServer.Listener.Addr().String()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + downstreamConn, rw, err := http.NewResponseController(w).Hijack() + if err != nil { + _ = upstreamConn.Close() + return + } + _, _ = rw.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n") + _ = rw.Flush() + go func() { + _, _ = io.Copy(upstreamConn, downstreamConn) + _ = upstreamConn.Close() + }() + _, _ = io.Copy(downstreamConn, upstreamConn) + _ = downstreamConn.Close() + })) + proxyServer.EnableHTTP2 = true + proxyServer.StartTLS() + t.Cleanup(proxyServer.Close) + + _, proxyPort, err := net.SplitHostPort(proxyServer.Listener.Addr().String()) + require.NoError(t, err) + proxyURL, err := url.Parse("https://" + net.JoinHostPort("bücher.example", proxyPort)) + require.NoError(t, err) + + serverTransport := proxyServer.Client().Transport.(*http.Transport) + tlsConfig := serverTransport.TLSClientConfig.Clone() + tlsConfig.ServerName = "example.com" + dialAddr := make(chan string, 1) + transport := &http.Transport{ + TLSClientConfig: tlsConfig, + ForceAttemptHTTP2: true, + Proxy: http.ProxyURL(proxyURL), + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + dialAddr <- addr + return (&net.Dialer{}).DialContext(ctx, network, proxyServer.Listener.Addr().String()) + }, + } + roundTripper := configureTransportForUpstream(transport) + t.Cleanup(transport.CloseIdleConnections) + + client := &http.Client{Transport: roundTripper} + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://destination.example.com", nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + require.Equal(t, proxyRequest{method: http.MethodConnect, protocol: "HTTP/1.1"}, <-proxyRequests) + require.Equal(t, "HTTP/2.0", <-originProtocol) + require.Equal(t, canonicalProxyAddr(proxyURL), <-dialAddr) +} + +func TestHTTPSUpstreamProxyPreservesHTTP2ForDirect(t *testing.T) { + protocol := make(chan string, 1) + origin := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + protocol <- r.Proto + w.WriteHeader(http.StatusNoContent) + })) + origin.EnableHTTP2 = true + origin.StartTLS() + t.Cleanup(origin.Close) + + transport := origin.Client().Transport.(*http.Transport).Clone() + transport.ForceAttemptHTTP2 = true + transport.Proxy = func(*http.Request) (*url.URL, error) { return nil, nil } + roundTripper := configureTransportForUpstream(transport) + t.Cleanup(transport.CloseIdleConnections) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, origin.URL, nil) + require.NoError(t, err) + + resp, err := roundTripper.RoundTrip(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + require.Equal(t, "HTTP/2.0", <-protocol) +} From e9606c76e0dc322e35a739ccdea24809fa559cd5 Mon Sep 17 00:00:00 2001 From: Guthrie McAfee Armstrong Date: Wed, 12 Aug 2026 14:01:46 -0400 Subject: [PATCH 2/2] docs: expand upstream proxy docs Signed-off-by: Guthrie McAfee Armstrong --- docs/proxy.md | 51 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/docs/proxy.md b/docs/proxy.md index 5c89742e5915..ebb66ea3e6b4 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -95,17 +95,46 @@ system trust store pass through the BuildKit proxy. ## Upstream proxies -The BuildKit proxy can chain requests through an upstream proxy using Go's -standard proxy environment handling, including HTTP(S) and SOCKS5 proxy URLs. - -The internal proxy inherits `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` from -the `buildkitd` environment. These settings apply to all proxy-network execs; -proxy settings passed to an individual exec do not change upstream routing. -The proxy URL values injected into each process still point to BuildKit's -internal proxy. `NO_PROXY` applies to the connection from the BuildKit proxy to -the destination, so matching requests connect directly. An invalid upstream -proxy URL prevents proxy-network execs from starting rather than falling back -to a direct connection. +To use an upstream proxy, set `HTTP_PROXY` and/or `HTTPS_PROXY` in the +`buildkitd` environment and enable proxy networking: + +```bash +HTTP_PROXY=http://proxy.example:3128 \ +HTTPS_PROXY=http://proxy.example:3128 \ +NO_PROXY=localhost,127.0.0.1,.example.internal \ +buildkitd --proxy-network +``` + +BuildKit uses Go's standard proxy environment handling: + +| Variable pair | Purpose | +| --- | --- | +| `HTTP_PROXY`, `http_proxy` | Selects the upstream proxy for HTTP destinations. | +| `HTTPS_PROXY`, `https_proxy` | Selects the upstream proxy for HTTPS destinations. | +| `NO_PROXY`, `no_proxy` | Lists destinations that bypass the upstream proxy. | + +For each pair, BuildKit uses the uppercase variable when it is non-empty and +falls back to the lowercase variable. The HTTP and HTTPS settings are +independent: `HTTP_PROXY` and `http_proxy` do not apply to HTTPS destinations. + +BuildKit injects `ALL_PROXY` and `all_proxy` into proxy-network execs. It does +not read these variables from the `buildkitd` environment when it configures +upstream routing. + +Proxy values can be complete `http://`, `https://`, `socks5://`, or +`socks5h://` URLs. A bare `host[:port]` uses HTTP. + +`NO_PROXY` is a comma-separated list of domain names, IP addresses, and CIDR +prefixes. Domain names and IP addresses can include a port. When a destination +matches the list, BuildKit's proxy connects to it directly. A value of `*` +makes direct connections to all destinations. `NO_PROXY` controls how the +BuildKit proxy reaches the destination. It does not change the proxy variables +in the exec. + +These settings apply to all proxy-network execs. Proxy settings passed to an +individual exec do not change upstream routing because its proxy variables +point to BuildKit's internal proxy. If an upstream proxy URL is invalid, the +proxy-network exec fails to start instead of connecting directly. ## Request capture and logs