From c4a6fcdffb2bddff95afeb5d3129a68766563646 Mon Sep 17 00:00:00 2001 From: Domantas Petrauskas Date: Sun, 7 Jun 2026 15:14:19 +0300 Subject: [PATCH 1/2] fix(publish): bypass Docker Desktop proxy for localhost/insecure registries compose publish routed all registry traffic through Docker Desktop's HTTP proxy, so publishing to a localhost/insecure registry failed on Windows with "proxyconnect tcp: open ./pipe/dockerHttpProxy: The system cannot find the path specified", while docker push/pull worked against the same registry. Two bugs in internal/desktop/proxy.go: - ProxyTransport forced every request through the DD proxy and its DialContext always dialed the proxy socket, so loopback targets could never connect directly. Select the proxy via httpproxy.Config.ProxyFunc, which exempts localhost/loopback and now also honors NO_PROXY; route only the sentinel proxy address to the DD socket and dial real targets directly otherwise. - The Windows named-pipe endpoint was hardcoded as npipe://./pipe/..., yielding the relative path ./pipe/dockerHttpProxy. Derive it from the engine endpoint, preserving the dialable npipe:////./pipe/ prefix. Fixes #13824 Co-Authored-By: Claude Opus 4.8 Signed-off-by: Domantas Petrauskas --- go.mod | 2 +- internal/desktop/proxy.go | 72 +++++++++++++++++++++++++++++++--- internal/desktop/proxy_test.go | 45 ++++++++++++++++++++- 3 files changed, 110 insertions(+), 9 deletions(-) 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..705fa803e0 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/dockerDesktopLinuxEngine → 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,18 @@ 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 (e.g. the `npipe:////./pipe/` form Docker Desktop reports), + // 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). + if idx := strings.LastIndex(endpoint, "/"); idx >= 0 { + return endpoint[:idx+1] + "dockerHttpProxy" + } + return "" } return "" } @@ -74,6 +93,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 +119,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..0d1333a396 100644 --- a/internal/desktop/proxy_test.go +++ b/internal/desktop/proxy_test.go @@ -48,8 +48,11 @@ func TestHTTPProxySocketEndpoint_UnixSocketMissing(t *testing.T) { } func TestHTTPProxySocketEndpoint_WindowsNamedPipe(t *testing.T) { - got := httpProxySocketEndpoint("npipe://./pipe/dockerCli") - assert.Equal(t, got, "npipe://./pipe/dockerHttpProxy") + // Docker Desktop reports the engine pipe in the dialable `npipe:////./pipe/` + // form; the derived proxy endpoint must keep that exact prefix and only swap + // the trailing pipe name (docker/compose#13824). + got := httpProxySocketEndpoint("npipe:////./pipe/dockerDesktopLinuxEngine") + assert.Equal(t, got, "npipe:////./pipe/dockerHttpProxy") } func TestHTTPProxySocketEndpoint_EmptyOrUnknown(t *testing.T) { @@ -99,6 +102,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) From 3343d48eb923e2e569c7d365837bac89214e9666 Mon Sep 17 00:00:00 2001 From: Domantas Petrauskas Date: Sun, 7 Jun 2026 15:56:40 +0300 Subject: [PATCH 2/2] fix(publish): derive proxy pipe path for the backslash npipe form End-to-end validation against Docker Desktop 29.5.2 showed it reports its engine endpoint as `npipe://\.\pipe\docker_cli` (backslash namespace), not the forward-slash form assumed earlier. LastIndex(endpoint, "/") then matched the slash in "npipe://" and produced `npipe://dockerHttpProxy`, dropping the `\.\pipe\` namespace. Use LastIndexAny(endpoint, `/\`) so both the backslash form Docker Desktop actually reports and the forward-slash form resolve to a dialable pipe path. Verified the published artifact lands in a localhost registry and that the parent commit still reproduces the original "proxyconnect tcp: open ./pipe/dockerHttpProxy" failure. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Domantas Petrauskas --- internal/desktop/proxy.go | 18 ++++++++++-------- internal/desktop/proxy_test.go | 32 +++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/internal/desktop/proxy.go b/internal/desktop/proxy.go index 705fa803e0..7b45fbefcb 100644 --- a/internal/desktop/proxy.go +++ b/internal/desktop/proxy.go @@ -59,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") @@ -73,12 +73,14 @@ func httpProxySocketEndpoint(endpoint string) string { // 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 (e.g. the `npipe:////./pipe/` form Docker Desktop reports), - // 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). - if idx := strings.LastIndex(endpoint, "/"); idx >= 0 { + // 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 "" diff --git a/internal/desktop/proxy_test.go b/internal/desktop/proxy_test.go index 0d1333a396..de97d5ae4b 100644 --- a/internal/desktop/proxy_test.go +++ b/internal/desktop/proxy_test.go @@ -48,11 +48,33 @@ func TestHTTPProxySocketEndpoint_UnixSocketMissing(t *testing.T) { } func TestHTTPProxySocketEndpoint_WindowsNamedPipe(t *testing.T) { - // Docker Desktop reports the engine pipe in the dialable `npipe:////./pipe/` - // form; the derived proxy endpoint must keep that exact prefix and only swap - // the trailing pipe name (docker/compose#13824). - got := httpProxySocketEndpoint("npipe:////./pipe/dockerDesktopLinuxEngine") - 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) {