diff --git a/go.mod b/go.mod index 01889fd320..5a90a7c536 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( go.uber.org/goleak v1.3.0 go.uber.org/mock v0.6.0 go.yaml.in/yaml/v4 v4.0.0-rc.4 + golang.org/x/net v0.51.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.44.0 google.golang.org/grpc v1.81.1 @@ -136,7 +137,6 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect diff --git a/internal/desktop/proxy.go b/internal/desktop/proxy.go index 0f2af1d0f0..7b45fbefcb 100644 --- a/internal/desktop/proxy.go +++ b/internal/desktop/proxy.go @@ -27,10 +27,18 @@ import ( "github.com/moby/moby/client" "github.com/sirupsen/logrus" + "golang.org/x/net/http/httpproxy" "github.com/docker/compose/v5/internal/memnet" ) +// ddProxyHost is a sentinel hostname stamped into the proxy URL handed to the +// stdlib transport. It is never resolved on the network: the transport's +// DialContext recognizes it and routes the connection to Docker Desktop's +// proxy socket instead. The .invalid TLD is reserved (RFC 6761) so it can +// never collide with a real registry host. +const ddProxyHost = "docker-desktop-http-proxy.invalid" + // Endpoint returns the Docker Desktop API socket endpoint advertised via the // engine info labels, or "" when the active engine is not Docker Desktop. func Endpoint(ctx context.Context, apiClient client.APIClient) (string, error) { @@ -51,8 +59,8 @@ func Endpoint(ctx context.Context, apiClient client.APIClient) (string, error) { // when the input is not a recognized form or when the derived unix socket // does not exist (older DD versions or non-DD installs). // -// On macOS/Linux: unix:///path/to/Data/docker-cli.sock → unix:///path/to/Data/httpproxy.sock -// On Windows: npipe://./pipe/dockerDesktopLinuxEngine → npipe://./pipe/dockerHttpProxy +// On macOS/Linux: unix:///path/to/Data/docker-cli.sock → unix:///path/to/Data/httpproxy.sock +// On Windows: npipe://\\.\pipe\docker_cli → npipe://\\.\pipe\dockerHttpProxy func httpProxySocketEndpoint(endpoint string) string { if sockPath, ok := strings.CutPrefix(endpoint, "unix://"); ok { proxyPath := filepath.Join(filepath.Dir(sockPath), "httpproxy.sock") @@ -62,7 +70,20 @@ func httpProxySocketEndpoint(endpoint string) string { return "unix://" + proxyPath } if strings.HasPrefix(endpoint, "npipe://") { - return "npipe://./pipe/dockerHttpProxy" + // Named pipes all live in the same `\\.\pipe\` namespace, so only the + // trailing pipe name differs. Swap it in place rather than rebuilding + // the endpoint string: this preserves the engine endpoint's exact + // prefix (Docker Desktop reports the backslash form + // `npipe://\\.\pipe\docker_cli`), which winio can dial. Hardcoding + // `npipe://./pipe/...` instead would yield the relative path + // `./pipe/dockerHttpProxy`, which fails with "open + // ./pipe/dockerHttpProxy: The system cannot find the path specified." + // (docker/compose#13824). LastIndexAny handles both the backslash form + // above and the forward-slash form `npipe:////./pipe/...`. + if idx := strings.LastIndexAny(endpoint, `/\`); idx >= 0 { + return endpoint[:idx+1] + "dockerHttpProxy" + } + return "" } return "" } @@ -74,6 +95,11 @@ func httpProxySocketEndpoint(endpoint string) string { // built-in transport). Pass "" for endpoint when DD is not the active // engine. // +// Loopback targets (localhost, 127.0.0.0/8, ::1) and any host matched by the +// NO_PROXY environment variable bypass the proxy and connect directly, so +// `compose publish` to a local/insecure registry behaves like `docker push` +// instead of failing inside the proxy (docker/compose#13824). +// // When DD is available, the returned transport is a clone of // http.DefaultTransport with only Proxy and DialContext overridden, so it // preserves stdlib timeout, pooling, and HTTP/2 defaults. @@ -95,13 +121,49 @@ func ProxyTransport(endpoint string) http.RoundTripper { } else { tr = &http.Transport{} } - tr.Proxy = http.ProxyURL(&url.URL{Scheme: "http"}) - tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { - return memnet.DialEndpoint(ctx, proxyEndpoint) + + tr.Proxy = ddProxyFunc() + + // Bypassed (direct) requests reach DialContext with their real target + // address and use the standard dialer; proxied requests reach it with the + // sentinel proxy address and are routed to the Docker Desktop socket. + baseDial := tr.DialContext + if baseDial == nil { + baseDial = (&net.Dialer{}).DialContext + } + proxyAddr := net.JoinHostPort(ddProxyHost, "80") + tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + if addr == proxyAddr { + return memnet.DialEndpoint(ctx, proxyEndpoint) + } + return baseDial(ctx, network, addr) } return tr } +// ddProxyFunc returns a transport Proxy function that selects the Docker +// Desktop proxy for every request except loopback targets and hosts excluded +// via NO_PROXY, which connect directly. It piggybacks on +// httpproxy.Config.ProxyFunc, which already exempts localhost and loopback +// addresses; the sentinel proxy URL is only used so that non-exempt requests +// resolve to ddProxyHost, which DialContext intercepts. +func ddProxyFunc() func(*http.Request) (*url.URL, error) { + proxyURL := (&url.URL{Scheme: "http", Host: ddProxyHost}).String() + noProxy := os.Getenv("NO_PROXY") + if noProxy == "" { + noProxy = os.Getenv("no_proxy") + } + cfg := &httpproxy.Config{ + HTTPProxy: proxyURL, + HTTPSProxy: proxyURL, + NoProxy: noProxy, + } + proxyFunc := cfg.ProxyFunc() + return func(req *http.Request) (*url.URL, error) { + return proxyFunc(req.URL) + } +} + // ProxyTransportFor discovers the Docker Desktop endpoint via apiClient and // returns the matching transport, or nil when DD is not active or discovery // fails (so callers fall back to their own default transport). diff --git a/internal/desktop/proxy_test.go b/internal/desktop/proxy_test.go index 460ec4181e..de97d5ae4b 100644 --- a/internal/desktop/proxy_test.go +++ b/internal/desktop/proxy_test.go @@ -48,8 +48,33 @@ func TestHTTPProxySocketEndpoint_UnixSocketMissing(t *testing.T) { } func TestHTTPProxySocketEndpoint_WindowsNamedPipe(t *testing.T) { - got := httpProxySocketEndpoint("npipe://./pipe/dockerCli") - assert.Equal(t, got, "npipe://./pipe/dockerHttpProxy") + // The derived proxy endpoint must keep the engine endpoint's exact prefix + // and only swap the trailing pipe name, so the result stays dialable by + // winio (docker/compose#13824). + cases := []struct { + name string + endpoint string + want string + }{ + { + // The form Docker Desktop actually reports (observed on DD 29.5.2): + // backslash `\\.\pipe\` namespace. + name: "backslash form (real Docker Desktop)", + endpoint: `npipe://\\.\pipe\docker_cli`, + want: `npipe://\\.\pipe\dockerHttpProxy`, + }, + { + // Forward-slash form some tooling uses; must work too. + name: "forward-slash form", + endpoint: "npipe:////./pipe/dockerDesktopLinuxEngine", + want: "npipe:////./pipe/dockerHttpProxy", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, httpProxySocketEndpoint(tc.endpoint), tc.want) + }) + } } func TestHTTPProxySocketEndpoint_EmptyOrUnknown(t *testing.T) { @@ -99,6 +124,44 @@ func TestProxyTransport_RoutesThroughDockerDesktop(t *testing.T) { assert.Equal(t, tr.ForceAttemptHTTP2, src.ForceAttemptHTTP2) } +// TestDDProxyFunc_BypassesLoopbackAndHonorsNoProxy exercises the proxy +// selection directly (rather than through ProxyTransport, which needs a live +// socket) so it runs on every platform, including Windows. This is the core of +// the docker/compose#13824 fix: loopback and NO_PROXY targets must connect +// directly instead of being forced through the Docker Desktop proxy. +func TestDDProxyFunc_BypassesLoopbackAndHonorsNoProxy(t *testing.T) { + t.Setenv("NO_PROXY", "registry.internal") + t.Setenv("no_proxy", "registry.internal") + + proxyFunc := ddProxyFunc() + + cases := []struct { + name string + reqURL string + wantProxy bool + }{ + {"loopback name", "http://localhost:5000/v2/", false}, + {"loopback IPv4", "http://127.0.0.1:5000/v2/", false}, + {"no_proxy host", "https://registry.internal/v2/", false}, + {"external https", "https://registry-1.docker.io/v2/", true}, + {"external http", "http://example.com/v2/", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, tc.reqURL, http.NoBody) + assert.NilError(t, err) + proxyURL, err := proxyFunc(req) + assert.NilError(t, err) + if tc.wantProxy { + assert.Assert(t, proxyURL != nil, "expected %s to route through the Docker Desktop proxy", tc.reqURL) + assert.Equal(t, proxyURL.Host, ddProxyHost) + } else { + assert.Assert(t, proxyURL == nil, "expected %s to bypass the proxy and connect directly", tc.reqURL) + } + }) + } +} + func mustTouch(t *testing.T, path string) { t.Helper() f, err := os.Create(path)