diff --git a/client/client_test.go b/client/client_test.go index d3e66f24f9ae..3b16893cb0a2 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -349,6 +349,7 @@ func testIntegration(t *testing.T, funcs ...func(t *testing.T, sb integration.Sa integration.Run(t, integration.TestFuncs( // policy_test.go testProxyNetworkNoRootless, + testProxyNetworkGatewayExecEnvNoRootless, testProxyNetworkModesNoRootless, testProxyNetworkDefaultEgressNoRootless, ), diff --git a/client/policy_test.go b/client/policy_test.go index b53cf8ff32a7..b88124750cda 100644 --- a/client/policy_test.go +++ b/client/policy_test.go @@ -1,6 +1,7 @@ package client import ( + "bytes" "context" "crypto" "crypto/sha256" @@ -34,6 +35,7 @@ import ( sourcepolicypb "github.com/moby/buildkit/sourcepolicy/pb" "github.com/moby/buildkit/sourcepolicy/policysession" "github.com/moby/buildkit/util/entitlements" + "github.com/moby/buildkit/util/iohelper" "github.com/moby/buildkit/util/pgpsign" "github.com/moby/buildkit/util/testutil/integration" "github.com/moby/buildkit/util/testutil/workers" @@ -253,6 +255,87 @@ func testProxyNetworkNoRootless(t *testing.T, sb integration.Sandbox) { require.Equal(t, "unsuccessful_response", materialsErr.Incomplete[0].Reason) } +func testProxyNetworkGatewayExecEnvNoRootless(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + childEnv := bytes.NewBuffer(nil) + + _, err = c.Build(ctx, SolveOpt{ProxyNetwork: true}, "proxy-network-gateway-exec-env", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + def, err := llb.Image("busybox:latest").Marshal(ctx) + if err != nil { + return nil, err + } + res, err := c.Solve(ctx, gateway.SolveRequest{Definition: def.ToPB()}) + if err != nil { + return nil, err + } + ctr, err := c.NewContainer(ctx, gateway.NewContainerRequest{ + Mounts: []gateway.Mount{{ + Dest: "/", + MountType: opspb.MountType_BIND, + Ref: res.Ref, + }}, + }) + if err != nil { + return nil, err + } + pid1, err := ctr.Start(ctx, gateway.StartRequest{ + Args: []string{"sleep", "30"}, + Env: []string{ + "INIT_ONLY=must-not-leak", + "ALL_PROXY=http://initial-process-proxy.invalid", + }, + }) + if err != nil { + _ = ctr.Release(context.WithoutCancel(ctx)) + return nil, err + } + defer func() { + _ = ctr.Release(context.WithoutCancel(ctx)) + _ = pid1.Wait() + }() + + pid2, err := ctr.Start(ctx, gateway.StartRequest{ + Args: []string{"env"}, + Env: []string{ + "CHILD_ENV=preserved", + "ALL_PROXY=http://child-process-proxy.invalid", + }, + Stdout: &iohelper.NopWriteCloser{Writer: childEnv}, + }) + if err != nil { + return nil, err + } + if err := pid2.Wait(); err != nil { + return nil, err + } + return &gateway.Result{}, nil + }, nil) + require.NoError(t, err) + + env := strings.Split(strings.TrimSpace(childEnv.String()), "\n") + require.Contains(t, env, "CHILD_ENV=preserved") + require.NotContains(t, env, "ALL_PROXY=http://child-process-proxy.invalid") + require.NotContains(t, env, "ALL_PROXY=http://initial-process-proxy.invalid") + require.NotContains(t, env, "INIT_ONLY=must-not-leak") + values := make(map[string]string, len(env)) + for _, entry := range env { + name, value, ok := strings.Cut(entry, "=") + if ok { + values[name] = value + } + } + for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "NO_PROXY", "no_proxy"} { + require.NotEmptyf(t, values[name], "%s is not set in the gateway exec environment:\n%s", name, childEnv.String()) + } + require.Equal(t, values["HTTP_PROXY"], values["ALL_PROXY"]) + require.Equal(t, values["http_proxy"], values["all_proxy"]) +} + func testProxyNetworkModesNoRootless(t *testing.T, sb integration.Sandbox) { integration.SkipOnPlatform(t, "windows") workers.CheckFeatureCompat(t, sb, workers.FeatureCNINetwork) diff --git a/docs/proxy.md b/docs/proxy.md index 932db2889fae..ebb66ea3e6b4 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,49 @@ 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 + +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 The proxy records network requests made by exec steps. Build output includes a @@ -126,6 +171,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..302098882e20 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 @@ -315,6 +315,10 @@ func (w *containerdExecutor) Exec(ctx context.Context, id string, process execut } proc := spec.Process + if meta.Proxy != nil && len(meta.Env) > 0 { + meta.Env = executor.ReplaceEnv(meta.Env, network.FilterProxyEnv(proc.Env)) + process.Meta = meta + } if meta.User != "" { userSpec, err := getUserSpec(meta.User, details.rootfsPath) if err != nil { @@ -332,8 +336,8 @@ func (w *containerdExecutor) Exec(ctx context.Context, id string, process execut if meta.Cwd != "" { spec.Process.Cwd = meta.Cwd } - if len(process.Meta.Env) > 0 { - spec.Process.Env = process.Meta.Env + if len(meta.Env) > 0 { + proc.Env = meta.Env } fixProcessOutput(&process) diff --git a/executor/env.go b/executor/env.go new file mode 100644 index 000000000000..f9c9b3468b07 --- /dev/null +++ b/executor/env.go @@ -0,0 +1,21 @@ +package executor + +import "strings" + +// 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...) +} 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/runcexecutor/executor.go b/executor/runcexecutor/executor.go index a301fecb294e..0838c5126f68 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() { @@ -436,6 +436,7 @@ func exitError(ctx context.Context, cgroupPath string, err error, validExitCodes } func (w *runcExecutor) Exec(ctx context.Context, id string, process executor.ProcessInfo) (err error) { + meta := process.Meta // first verify the container is running, if we get an error assume the container // is in the process of being created and check again every 100ms or until // context is canceled. @@ -479,9 +480,13 @@ func (w *runcExecutor) Exec(ctx context.Context, id string, process executor.Pro if _, err := dec.Token(); !errors.Is(err, io.EOF) { return errors.Errorf("unexpected data after JSON spec object") } + if meta.Proxy != nil && len(meta.Env) > 0 { + meta.Env = executor.ReplaceEnv(meta.Env, network.FilterProxyEnv(spec.Process.Env)) + process.Meta = meta + } - if process.Meta.User != "" { - uid, gid, sgids, err := oci.GetUser(state.Rootfs, process.Meta.User) + if meta.User != "" { + uid, gid, sgids, err := oci.GetUser(state.Rootfs, meta.User) if err != nil { return err } @@ -492,14 +497,14 @@ func (w *runcExecutor) Exec(ctx context.Context, id string, process executor.Pro } } - spec.Process.Terminal = process.Meta.Tty - spec.Process.Args = process.Meta.Args - if process.Meta.Cwd != "" { - spec.Process.Cwd = process.Meta.Cwd + spec.Process.Terminal = meta.Tty + spec.Process.Args = meta.Args + if meta.Cwd != "" { + spec.Process.Cwd = meta.Cwd } - if len(process.Meta.Env) > 0 { - spec.Process.Env = process.Meta.Env + if len(meta.Env) > 0 { + spec.Process.Env = meta.Env } err = w.exec(ctx, id, spec.Process, process, nil) diff --git a/util/network/proxy.go b/util/network/proxy.go index b79aa922a7a1..3023dde76bc3 100644 --- a/util/network/proxy.go +++ b/util/network/proxy.go @@ -4,6 +4,7 @@ import ( "context" "io" "slices" + "strings" "sync" "github.com/moby/buildkit/solver/pb" @@ -34,6 +35,53 @@ type ProxyNamespace interface { ProxyCACert() []byte } +var proxyEnvNames = [...]struct { + name string + noProxy bool +}{ + {name: "HTTP_PROXY"}, + {name: "HTTPS_PROXY"}, + {name: "ALL_PROXY"}, + {name: "http_proxy"}, + {name: "https_proxy"}, + {name: "all_proxy"}, + {name: "NO_PROXY", noProxy: true}, + {name: "no_proxy", noProxy: true}, +} + +// ProxyEnv returns the environment entries used to configure a process to use +// a BuildKit-owned HTTP(S) proxy. +func ProxyEnv(proxy, noProxy string) []string { + out := make([]string, 0, len(proxyEnvNames)) + for _, env := range proxyEnvNames { + value := proxy + if env.noProxy { + value = noProxy + } + out = append(out, env.name+"="+value) + } + return out +} + +// FilterProxyEnv returns entries whose names are emitted by ProxyEnv, preserving +// their original order. +func FilterProxyEnv(env []string) []string { + out := make([]string, 0, len(proxyEnvNames)) + for _, entry := range env { + name, _, ok := strings.Cut(entry, "=") + if !ok { + continue + } + for _, proxyEnv := range proxyEnvNames { + if name == proxyEnv.name { + out = append(out, entry) + break + } + } + } + return out +} + type ProxyMaterial struct { URL string Digest digest.Digest diff --git a/util/network/proxy_test.go b/util/network/proxy_test.go new file mode 100644 index 000000000000..26a5df671034 --- /dev/null +++ b/util/network/proxy_test.go @@ -0,0 +1,23 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFilterProxyEnv(t *testing.T) { + require.Equal(t, []string{ + "HTTP_PROXY=http://buildkit-proxy", + "ALL_PROXY=http://initial-process-proxy", + "all_proxy=http://initial-process-proxy", + "NO_PROXY=localhost", + }, FilterProxyEnv([]string{ + "PATH=/usr/bin", + "HTTP_PROXY=http://buildkit-proxy", + "FTP_PROXY=http://ftp-proxy", + "ALL_PROXY=http://initial-process-proxy", + "all_proxy=http://initial-process-proxy", + "NO_PROXY=localhost", + })) +} diff --git a/util/network/proxyprovider/provider_linux.go b/util/network/proxyprovider/provider_linux.go index 0d64382fcdbe..834d675fc06e 100644 --- a/util/network/proxyprovider/provider_linux.go +++ b/util/network/proxyprovider/provider_linux.go @@ -278,14 +278,7 @@ func (n *proxyNS) Sample() (*resourcestypes.NetworkSample, error) { func (n *proxyNS) ProxyEnv() []string { proxy := "http://" + n.ln.Addr().String() noProxy := "127.0.0.1,localhost,::1" - return []string{ - "HTTP_PROXY=" + proxy, - "HTTPS_PROXY=" + proxy, - "http_proxy=" + proxy, - "https_proxy=" + proxy, - "NO_PROXY=" + noProxy, - "no_proxy=" + noProxy, - } + return network.ProxyEnv(proxy, noProxy) } func (n *proxyNS) ProxyCACert() []byte { @@ -381,6 +374,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 +399,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 +505,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..b7a6c2fc1884 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.Dialer{}).DialContext(r.Context(), "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.ListenConfig{}).Listen(t.Context(), "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..d3330614c3d1 --- /dev/null +++ b/util/network/proxyprovider/upstream.go @@ -0,0 +1,177 @@ +package proxyprovider + +import ( + "context" + "crypto/tls" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "unicode/utf8" + + "github.com/pkg/errors" + "golang.org/x/net/idna" +) + +// upstreamProxyEnvironment validates the proxy environment before a proxy +// namespace starts. The boolean reports whether an HTTPS proxy needs the +// transport compatibility wrapper below. +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 { + // Proxy URLs may contain credentials, so neither the value nor the + // parser error is safe to include here. + return false, errors.Errorf("invalid %s in buildkitd environment", name) + } + if 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, errors.WithStack(err) + } + if proxyURL.Hostname() == "" { + return nil, errors.Errorf("proxy URL with scheme %q is missing host", proxyURL.Scheme) + } + switch proxyURL.Scheme { + case "http", "https", "socks5", "socks5h": + default: + return nil, errors.Errorf("unsupported proxy URL scheme %q", proxyURL.Scheme) + } + return proxyURL, nil +} + +func canonicalHTTPSProxyAddr(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 == "" { + port = "443" + } + 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 decision so concurrent dials see immutable state + // and retries use the same proxy. + s.once.Do(func() { + s.proxyURL, s.err = proxyFunc(req) + if s.err == nil && s.proxyURL != nil && s.proxyURL.Scheme == "https" { + s.httpsAddr = canonicalHTTPSProxyAddr(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. The +// standard transport 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, errors.WithStack(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, errors.WithStack(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.WithTimeoutCause(ctx, transport.TLSHandshakeTimeout, errors.WithStack(context.DeadlineExceeded)) + defer cancel() + } + if err := tlsConn.HandshakeContext(handshakeCtx); err != nil { + _ = conn.Close() + return nil, errors.WithStack(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..37d97d5a7c1a --- /dev/null +++ b/util/network/proxyprovider/upstream_test.go @@ -0,0 +1,230 @@ +package proxyprovider + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/pkg/errors" + "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("HTTP_PROXY", "https://proxy.example:443") + hasHTTPSProxy, err = upstreamProxyEnvironment() + require.NoError(t, err) + require.True(t, hasHTTPSProxy) + + t.Setenv("HTTP_PROXY", "") + t.Setenv("HTTPS_PROXY", "") + t.Setenv("https_proxy", "https://proxy.example:443") + hasHTTPSProxy, err = upstreamProxyEnvironment() + require.NoError(t, err) + require.True(t, hasHTTPSProxy) + + // Uppercase 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 in buildkitd environment") + 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 in buildkitd environment") + require.NotContains(t, err.Error(), "other-secret") + + t.Setenv("https_proxy", "") + t.Setenv("HTTP_PROXY", "/") + _, err = upstreamProxyEnvironment() + require.EqualError(t, err, "invalid HTTP_PROXY in buildkitd environment") + + t.Setenv("HTTP_PROXY", "http://:80") + _, err = upstreamProxyEnvironment() + require.EqualError(t, err, "invalid HTTP_PROXY in buildkitd environment") + + t.Setenv("HTTP_PROXY", "ftp://user:scheme-secret@proxy.example:21") + _, err = upstreamProxyEnvironment() + require.EqualError(t, err, "invalid HTTP_PROXY in buildkitd environment") + 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.Dialer{}).DialContext(r.Context(), "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, canonicalHTTPSProxyAddr(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) +}