Skip to content

Commit a365264

Browse files
committed
fix: route OCI artifact pulls through Docker Desktop HTTP proxy
The compose process performs OCI artifact fetches in-process via containerd's docker resolver, whose default transport only honors HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars. Users behind PAC-only corporate proxies hit i/o timeouts on `oci://` includes and on `compose publish`. When Docker Desktop is the active engine and exposes httpproxy.sock, route the resolver through it (PAC-aware). Falls back to the default transport when DD is unavailable or the socket is missing. Modeled on docker/mcp-gateway PR #354. Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
1 parent baaaaa3 commit a365264

7 files changed

Lines changed: 317 additions & 46 deletions

File tree

internal/desktop/proxy.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
Copyright 2026 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package desktop
18+
19+
import (
20+
"context"
21+
"net"
22+
"net/http"
23+
"net/url"
24+
"os"
25+
"path/filepath"
26+
"strings"
27+
28+
"github.com/moby/moby/client"
29+
"github.com/sirupsen/logrus"
30+
31+
"github.com/docker/compose/v5/internal/memnet"
32+
)
33+
34+
// Endpoint returns the Docker Desktop API socket endpoint advertised via the
35+
// engine info labels, or "" when the active engine is not Docker Desktop.
36+
func Endpoint(ctx context.Context, apiClient client.APIClient) (string, error) {
37+
res, err := apiClient.Info(ctx, client.InfoOptions{})
38+
if err != nil {
39+
return "", err
40+
}
41+
for _, l := range res.Info.Labels {
42+
if k, v, ok := strings.Cut(l, "="); ok && k == EngineLabel {
43+
return v, nil
44+
}
45+
}
46+
return "", nil
47+
}
48+
49+
// httpProxySocketEndpoint derives Docker Desktop's HTTP proxy socket endpoint
50+
// from a Docker Desktop socket endpoint in the same directory. Returns ""
51+
// when the input is not a recognized form or when the derived unix socket
52+
// does not exist (older DD versions or non-DD installs).
53+
//
54+
// On macOS/Linux: unix:///path/to/Data/docker-cli.sock → unix:///path/to/Data/httpproxy.sock
55+
// On Windows: npipe://./pipe/dockerCli → npipe://./pipe/dockerHttpProxy
56+
func httpProxySocketEndpoint(endpoint string) string {
57+
if sockPath, ok := strings.CutPrefix(endpoint, "unix://"); ok {
58+
proxyPath := filepath.Join(filepath.Dir(sockPath), "httpproxy.sock")
59+
if _, err := os.Stat(proxyPath); err != nil {
60+
return ""
61+
}
62+
return "unix://" + proxyPath
63+
}
64+
if strings.HasPrefix(endpoint, "npipe://") {
65+
return "npipe://./pipe/dockerHttpProxy"
66+
}
67+
return ""
68+
}
69+
70+
// ProxyTransport returns an http.RoundTripper that routes traffic through
71+
// Docker Desktop's PAC-aware HTTP proxy when DD exposes the proxy socket.
72+
// Otherwise http.DefaultTransport is returned. Pass "" for endpoint when DD
73+
// is not the active engine.
74+
func ProxyTransport(endpoint string) http.RoundTripper {
75+
proxyEndpoint := httpProxySocketEndpoint(endpoint)
76+
if proxyEndpoint == "" {
77+
logrus.Debug("Docker Desktop HTTP proxy not available; using default HTTP transport")
78+
return http.DefaultTransport
79+
}
80+
logrus.Debugf("routing OCI traffic through Docker Desktop HTTP proxy at %s", proxyEndpoint)
81+
return &http.Transport{
82+
Proxy: http.ProxyURL(&url.URL{Scheme: "http"}),
83+
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
84+
return memnet.DialEndpoint(ctx, proxyEndpoint)
85+
},
86+
}
87+
}
88+
89+
// ProxyTransportFor discovers the Docker Desktop endpoint via apiClient and
90+
// returns the matching transport, falling back to http.DefaultTransport on
91+
// discovery failure.
92+
func ProxyTransportFor(ctx context.Context, apiClient client.APIClient) http.RoundTripper {
93+
endpoint, err := Endpoint(ctx, apiClient)
94+
if err != nil {
95+
logrus.Debugf("could not detect Docker Desktop endpoint, using default HTTP transport: %v", err)
96+
return http.DefaultTransport
97+
}
98+
return ProxyTransport(endpoint)
99+
}

internal/desktop/proxy_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
Copyright 2026 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package desktop
18+
19+
import (
20+
"net/http"
21+
"os"
22+
"path/filepath"
23+
"runtime"
24+
"testing"
25+
26+
"gotest.tools/v3/assert"
27+
)
28+
29+
func TestHTTPProxySocketEndpoint_UnixSocketExists(t *testing.T) {
30+
dir := t.TempDir()
31+
cliSock := filepath.Join(dir, "docker-cli.sock")
32+
proxySock := filepath.Join(dir, "httpproxy.sock")
33+
mustTouch(t, cliSock)
34+
mustTouch(t, proxySock)
35+
36+
got := httpProxySocketEndpoint("unix://" + cliSock)
37+
assert.Equal(t, got, "unix://"+proxySock)
38+
}
39+
40+
func TestHTTPProxySocketEndpoint_UnixSocketMissing(t *testing.T) {
41+
// httpproxy.sock deliberately not created — older DD or partial install.
42+
dir := t.TempDir()
43+
cliSock := filepath.Join(dir, "docker-cli.sock")
44+
mustTouch(t, cliSock)
45+
46+
got := httpProxySocketEndpoint("unix://" + cliSock)
47+
assert.Equal(t, got, "", "stat miss must fall back so callers do not dial a non-existent socket")
48+
}
49+
50+
func TestHTTPProxySocketEndpoint_WindowsNamedPipe(t *testing.T) {
51+
got := httpProxySocketEndpoint("npipe://./pipe/dockerCli")
52+
assert.Equal(t, got, "npipe://./pipe/dockerHttpProxy")
53+
}
54+
55+
func TestHTTPProxySocketEndpoint_EmptyOrUnknown(t *testing.T) {
56+
assert.Equal(t, httpProxySocketEndpoint(""), "")
57+
assert.Equal(t, httpProxySocketEndpoint("tcp://localhost:1234"), "")
58+
}
59+
60+
func TestProxyTransport_FallsBackWhenNoDockerDesktop(t *testing.T) {
61+
got := ProxyTransport("")
62+
assert.Equal(t, got, http.DefaultTransport)
63+
}
64+
65+
func TestProxyTransport_FallsBackWhenSocketMissing(t *testing.T) {
66+
// no httpproxy.sock created
67+
dir := t.TempDir()
68+
cliSock := filepath.Join(dir, "docker-cli.sock")
69+
mustTouch(t, cliSock)
70+
71+
got := ProxyTransport("unix://" + cliSock)
72+
assert.Equal(t, got, http.DefaultTransport,
73+
"when DD endpoint is set but proxy socket is missing, must not return a transport that would dial a dead socket")
74+
}
75+
76+
func TestProxyTransport_RoutesThroughDockerDesktop(t *testing.T) {
77+
if runtime.GOOS == "windows" {
78+
t.Skip("unix sockets test path; Windows uses named pipes which os.Stat handles differently")
79+
}
80+
dir := t.TempDir()
81+
cliSock := filepath.Join(dir, "docker-cli.sock")
82+
proxySock := filepath.Join(dir, "httpproxy.sock")
83+
mustTouch(t, cliSock)
84+
mustTouch(t, proxySock)
85+
86+
got := ProxyTransport("unix://" + cliSock)
87+
tr, ok := got.(*http.Transport)
88+
assert.Assert(t, ok, "expected *http.Transport when DD endpoint is set and socket exists")
89+
assert.Assert(t, tr != http.DefaultTransport)
90+
}
91+
92+
func mustTouch(t *testing.T, path string) {
93+
t.Helper()
94+
f, err := os.Create(path)
95+
assert.NilError(t, err)
96+
assert.NilError(t, f.Close())
97+
}

internal/oci/resolver.go

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package oci
1919
import (
2020
"context"
2121
"io"
22+
"net/http"
2223
"net/url"
2324
"slices"
2425
"strings"
@@ -35,28 +36,35 @@ import (
3536
"github.com/docker/compose/v5/internal/registry"
3637
)
3738

38-
// NewResolver setup an OCI Resolver based on docker/cli config to provide registry credentials
39-
func NewResolver(config *configfile.ConfigFile, insecureRegistries ...string) remotes.Resolver {
40-
return docker.NewResolver(docker.ResolverOptions{
41-
Hosts: docker.ConfigureDefaultRegistries(
42-
docker.WithAuthorizer(docker.NewDockerAuthorizer(
43-
docker.WithAuthCreds(func(host string) (string, string, error) {
44-
host = registry.GetAuthConfigKey(host)
45-
auth, err := config.GetAuthConfig(host)
46-
if err != nil {
47-
return "", "", err
48-
}
49-
if auth.IdentityToken != "" {
50-
return "", auth.IdentityToken, nil
51-
}
52-
return auth.Username, auth.Password, nil
53-
}),
54-
)),
55-
docker.WithPlainHTTP(func(domain string) (bool, error) {
56-
// Should be used for testing **only**
57-
return slices.Contains(insecureRegistries, domain), nil
39+
// NewResolver sets up an OCI Resolver based on docker/cli config to provide
40+
// registry credentials. When transport is non-nil it is used as the HTTP
41+
// transport for all registry calls (e.g. to route through Docker Desktop's
42+
// PAC-aware proxy); nil falls back to containerd's default transport.
43+
func NewResolver(config *configfile.ConfigFile, transport http.RoundTripper, insecureRegistries ...string) remotes.Resolver {
44+
opts := []docker.RegistryOpt{
45+
docker.WithAuthorizer(docker.NewDockerAuthorizer(
46+
docker.WithAuthCreds(func(host string) (string, string, error) {
47+
host = registry.GetAuthConfigKey(host)
48+
auth, err := config.GetAuthConfig(host)
49+
if err != nil {
50+
return "", "", err
51+
}
52+
if auth.IdentityToken != "" {
53+
return "", auth.IdentityToken, nil
54+
}
55+
return auth.Username, auth.Password, nil
5856
}),
59-
),
57+
)),
58+
docker.WithPlainHTTP(func(domain string) (bool, error) {
59+
// Should be used for testing **only**
60+
return slices.Contains(insecureRegistries, domain), nil
61+
}),
62+
}
63+
if transport != nil {
64+
opts = append(opts, docker.WithClient(&http.Client{Transport: transport}))
65+
}
66+
return docker.NewResolver(docker.ResolverOptions{
67+
Hosts: docker.ConfigureDefaultRegistries(opts...),
6068
})
6169
}
6270

internal/oci/resolver_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
Copyright 2026 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package oci
18+
19+
import (
20+
"net/http"
21+
"net/http/httptest"
22+
"sync/atomic"
23+
"testing"
24+
25+
"github.com/docker/cli/cli/config/configfile"
26+
"gotest.tools/v3/assert"
27+
)
28+
29+
// recordingRoundTripper counts RoundTrip invocations on a delegate so tests
30+
// can verify a supplied transport is actually used by the resolver.
31+
type recordingRoundTripper struct {
32+
delegate http.RoundTripper
33+
calls atomic.Int32
34+
}
35+
36+
func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
37+
r.calls.Add(1)
38+
return r.delegate.RoundTrip(req)
39+
}
40+
41+
// TestNewResolver_UsesProvidedTransport guards that the transport passed to
42+
// NewResolver actually carries OCI traffic. The httptest server returns 401
43+
// so the resolver fails fast without real network access.
44+
func TestNewResolver_UsesProvidedTransport(t *testing.T) {
45+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
46+
w.WriteHeader(http.StatusUnauthorized)
47+
}))
48+
t.Cleanup(server.Close)
49+
50+
host := server.Listener.Addr().String()
51+
rec := &recordingRoundTripper{delegate: http.DefaultTransport}
52+
53+
// Mark the test host insecure so the resolver uses HTTP scheme; this
54+
// avoids needing a TLS cert chain just to exercise plumbing.
55+
resolver := NewResolver(&configfile.ConfigFile{}, rec, host)
56+
57+
// We expect 401, but only care that the request reached our transport.
58+
_, _, _ = resolver.Resolve(t.Context(), host+"/test/image:latest")
59+
60+
assert.Assert(t, rec.calls.Load() > 0,
61+
"resolver did not invoke the supplied transport — wiring is broken")
62+
}
63+
64+
func TestNewResolver_NilTransportIsValid(t *testing.T) {
65+
resolver := NewResolver(&configfile.ConfigFile{}, nil)
66+
assert.Assert(t, resolver != nil, "NewResolver must return a non-nil resolver when transport is nil")
67+
}

pkg/compose/desktop.go

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,12 @@ package compose
1818

1919
import (
2020
"context"
21-
"strings"
22-
23-
"github.com/moby/moby/client"
2421

2522
"github.com/docker/compose/v5/internal/desktop"
2623
)
2724

28-
// desktopEndpoint returns the Docker Desktop API socket address discovered
29-
// from the Docker engine info labels. It returns "" when the active engine
30-
// is not a Docker Desktop instance.
3125
func (s *composeService) desktopEndpoint(ctx context.Context) (string, error) {
32-
res, err := s.apiClient().Info(ctx, client.InfoOptions{})
33-
if err != nil {
34-
return "", err
35-
}
36-
for _, l := range res.Info.Labels {
37-
k, v, ok := strings.Cut(l, "=")
38-
if ok && k == desktop.EngineLabel {
39-
return v, nil
40-
}
41-
}
42-
return "", nil
26+
return desktop.Endpoint(ctx, s.apiClient())
4327
}
4428

4529
// isDesktopIntegrationActive returns true when Docker Desktop is the active engine.

pkg/compose/publish.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
v1 "github.com/opencontainers/image-spec/specs-go/v1"
3838
"github.com/sirupsen/logrus"
3939

40+
"github.com/docker/compose/v5/internal/desktop"
4041
"github.com/docker/compose/v5/internal/oci"
4142
"github.com/docker/compose/v5/pkg/api"
4243
"github.com/docker/compose/v5/pkg/compose/transform"
@@ -94,7 +95,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
9495
insecureRegistries = append(insecureRegistries, reference.Domain(named))
9596
}
9697

97-
resolver := oci.NewResolver(s.configFile(), insecureRegistries...)
98+
resolver := oci.NewResolver(s.configFile(), desktop.ProxyTransportFor(ctx, s.apiClient()), insecureRegistries...)
9899

99100
descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion)
100101
if err != nil {

0 commit comments

Comments
 (0)