Browse code

Update host resolver to use containerd host config

Signed-off-by: Derek McGowan <derek@mcg.dev>

Derek McGowan authored on 2024/02/14 08:38:27
Showing 7 changed files
... ...
@@ -2,8 +2,6 @@ package containerd
2 2
 
3 3
 import (
4 4
 	"context"
5
-	"crypto/tls"
6
-	"errors"
7 5
 	"net/http"
8 6
 
9 7
 	"github.com/containerd/containerd/remotes"
... ...
@@ -33,11 +31,12 @@ func (i *ImageService) newResolverFromAuthConfig(ctx context.Context, authConfig
33 33
 }
34 34
 
35 35
 func hostsWrapper(hostsFn docker.RegistryHosts, optAuthConfig *registrytypes.AuthConfig, ref reference.Named, regService registryResolver) docker.RegistryHosts {
36
-	var authorizer docker.Authorizer
37
-	if optAuthConfig != nil {
38
-		authorizer = authorizerFromAuthConfig(*optAuthConfig, ref)
36
+	if optAuthConfig == nil {
37
+		return hostsFn
39 38
 	}
40 39
 
40
+	authorizer := authorizerFromAuthConfig(*optAuthConfig, ref)
41
+
41 42
 	return func(n string) ([]docker.RegistryHost, error) {
42 43
 		hosts, err := hostsFn(n)
43 44
 		if err != nil {
... ...
@@ -45,13 +44,7 @@ func hostsWrapper(hostsFn docker.RegistryHosts, optAuthConfig *registrytypes.Aut
45 45
 		}
46 46
 
47 47
 		for i := range hosts {
48
-			if hosts[i].Authorizer == nil {
49
-				hosts[i].Authorizer = authorizer
50
-				isInsecure := regService.IsInsecureRegistry(hosts[i].Host)
51
-				if hosts[i].Client.Transport != nil && isInsecure {
52
-					hosts[i].Client.Transport = httpFallback{super: hosts[i].Client.Transport}
53
-				}
54
-			}
48
+			hosts[i].Authorizer = authorizer
55 49
 		}
56 50
 		return hosts, nil
57 51
 	}
... ...
@@ -111,24 +104,3 @@ func (a *bearerAuthorizer) AddResponses(context.Context, []*http.Response) error
111 111
 	// Return not implemented to prevent retry of the request when bearer did not succeed
112 112
 	return cerrdefs.ErrNotImplemented
113 113
 }
114
-
115
-type httpFallback struct {
116
-	super http.RoundTripper
117
-}
118
-
119
-func (f httpFallback) RoundTrip(r *http.Request) (*http.Response, error) {
120
-	resp, err := f.super.RoundTrip(r)
121
-	var tlsErr tls.RecordHeaderError
122
-	if errors.As(err, &tlsErr) && string(tlsErr.RecordHeader[:]) == "HTTP/" {
123
-		// server gave HTTP response to HTTPS client
124
-		plainHttpUrl := *r.URL
125
-		plainHttpUrl.Scheme = "http"
126
-
127
-		plainHttpRequest := *r
128
-		plainHttpRequest.URL = &plainHttpUrl
129
-
130
-		return http.DefaultTransport.RoundTrip(&plainHttpRequest)
131
-	}
132
-
133
-	return resp, err
134
-}
... ...
@@ -1,10 +1,10 @@
1 1
 package daemon // import "github.com/docker/docker/daemon"
2 2
 
3 3
 import (
4
+	"context"
4 5
 	"crypto/tls"
5 6
 	"crypto/x509"
6 7
 	"fmt"
7
-	"net"
8 8
 	"net/http"
9 9
 	"net/url"
10 10
 	"os"
... ...
@@ -12,13 +12,11 @@ import (
12 12
 	"path/filepath"
13 13
 	"runtime"
14 14
 	"strings"
15
-	"time"
16 15
 
17 16
 	"github.com/containerd/containerd/remotes/docker"
17
+	hostconfig "github.com/containerd/containerd/remotes/docker/config"
18
+	cerrdefs "github.com/containerd/errdefs"
18 19
 	"github.com/docker/docker/registry"
19
-	"github.com/moby/buildkit/util/resolver/config"
20
-	resolverconfig "github.com/moby/buildkit/util/resolver/config"
21
-	"github.com/moby/buildkit/util/tracing"
22 20
 	"github.com/pkg/errors"
23 21
 )
24 22
 
... ...
@@ -29,159 +27,120 @@ const (
29 29
 // RegistryHosts returns the registry hosts configuration for the host component
30 30
 // of a distribution image reference.
31 31
 func (daemon *Daemon) RegistryHosts(host string) ([]docker.RegistryHost, error) {
32
-	m := map[string]resolverconfig.RegistryConfig{
33
-		"docker.io": {Mirrors: daemon.registryService.ServiceConfig().Mirrors},
32
+	hosts, err := hostconfig.ConfigureHosts(context.Background(), hostconfig.HostOptions{
33
+		// TODO: Also check containerd path when updating containerd to use multiple host directories
34
+		HostDir: hostconfig.HostDirFromRoot(registry.CertsDir()),
35
+	})(host)
36
+	if err != nil {
37
+		return nil, err
34 38
 	}
35
-	conf := daemon.registryService.ServiceConfig().IndexConfigs
36
-	for k, v := range conf {
37
-		c := m[k]
38
-		if !v.Secure {
39
-			t := true
40
-			c.PlainHTTP = &t
41
-			c.Insecure = &t
39
+
40
+	// Merge in legacy configuration if provided and only a single configuration
41
+	if sc := daemon.registryService.ServiceConfig(); len(hosts) == 1 && sc != nil {
42
+		hosts, err = daemon.mergeLegacyConfig(host, hosts)
43
+		if err != nil {
44
+			return nil, err
42 45
 		}
43
-		m[k] = c
44
-	}
45
-	if c, ok := m[host]; !ok && daemon.registryService.IsInsecureRegistry(host) {
46
-		t := true
47
-		c.PlainHTTP = &t
48
-		c.Insecure = &t
49
-		m[host] = c
50 46
 	}
51 47
 
52
-	for k, v := range m {
53
-		v.TLSConfigDir = []string{registry.HostCertsDir(k)}
54
-		m[k] = v
48
+	return hosts, nil
49
+}
50
+
51
+func (daemon *Daemon) mergeLegacyConfig(host string, hosts []docker.RegistryHost) ([]docker.RegistryHost, error) {
52
+	if len(hosts) == 0 {
53
+		return hosts, nil
55 54
 	}
55
+	sc := daemon.registryService.ServiceConfig()
56
+	if host == "docker.io" && len(sc.Mirrors) > 0 {
57
+		var mirrorHosts []docker.RegistryHost
58
+		for _, mirror := range sc.Mirrors {
59
+			h := hosts[0]
60
+			h.Capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve
56 61
 
57
-	certsDir := registry.CertsDir()
58
-	if fis, err := os.ReadDir(certsDir); err == nil {
59
-		for _, fi := range fis {
60
-			if _, ok := m[fi.Name()]; !ok {
61
-				m[fi.Name()] = resolverconfig.RegistryConfig{
62
-					TLSConfigDir: []string{filepath.Join(certsDir, fi.Name())},
62
+			u, err := url.Parse(mirror)
63
+			if err != nil || u.Host == "" {
64
+				u, err = url.Parse(fmt.Sprintf("//%s", mirror))
65
+			}
66
+			if err == nil && u.Host != "" {
67
+				h.Host = u.Host
68
+				h.Path = strings.TrimRight(u.Path, "/")
69
+				if !strings.HasSuffix(h.Path, defaultPath) {
70
+					h.Path = path.Join(defaultPath, h.Path)
63 71
 				}
72
+			} else {
73
+				h.Host = mirror
74
+				h.Path = defaultPath
64 75
 			}
76
+
77
+			mirrorHosts = append(mirrorHosts, h)
65 78
 		}
79
+		hosts = append(mirrorHosts, hosts[0])
66 80
 	}
67
-
68
-	return newRegistryConfig(m)(host)
69
-}
70
-
71
-// newRegistryConfig converts registry config to docker.RegistryHosts callback
72
-func newRegistryConfig(m map[string]resolverconfig.RegistryConfig) docker.RegistryHosts {
73
-	return docker.Registries(
74
-		func(host string) ([]docker.RegistryHost, error) {
75
-			c, ok := m[host]
76
-			if !ok {
77
-				return nil, nil
78
-			}
79
-
80
-			var out []docker.RegistryHost
81
-
82
-			for _, rawMirror := range c.Mirrors {
83
-				h := newMirrorRegistryHost(rawMirror)
84
-				mirrorHost := h.Host
85
-				host, err := fillInsecureOpts(mirrorHost, m[mirrorHost], h)
81
+	hostDir := hostconfig.HostDirFromRoot(registry.CertsDir())
82
+	for i := range hosts {
83
+		t, ok := hosts[i].Client.Transport.(*http.Transport)
84
+		if !ok {
85
+			continue
86
+		}
87
+		if t.TLSClientConfig == nil {
88
+			certsDir, err := hostDir(host)
89
+			if err != nil && !cerrdefs.IsNotFound(err) {
90
+				return nil, err
91
+			} else if err == nil {
92
+				c, err := loadTLSConfig(certsDir)
86 93
 				if err != nil {
87 94
 					return nil, err
88 95
 				}
89
-
90
-				out = append(out, *host)
96
+				t.TLSClientConfig = c
91 97
 			}
92
-
93
-			if host == "docker.io" {
94
-				host = "registry-1.docker.io"
95
-			}
96
-
97
-			h := docker.RegistryHost{
98
-				Scheme:       "https",
99
-				Client:       newDefaultClient(),
100
-				Host:         host,
101
-				Path:         "/v2",
102
-				Capabilities: docker.HostCapabilityPush | docker.HostCapabilityPull | docker.HostCapabilityResolve,
103
-			}
104
-
105
-			hosts, err := fillInsecureOpts(host, c, h)
106
-			if err != nil {
107
-				return nil, err
98
+		}
99
+		if daemon.registryService.IsInsecureRegistry(hosts[i].Host) {
100
+			if t.TLSClientConfig != nil {
101
+				isLocalhost, err := docker.MatchLocalhost(hosts[i].Host)
102
+				if err != nil {
103
+					continue
104
+				}
105
+				if isLocalhost {
106
+					hosts[i].Client.Transport = docker.NewHTTPFallback(hosts[i].Client.Transport)
107
+				}
108
+				t.TLSClientConfig.InsecureSkipVerify = true
109
+			} else {
110
+				hosts[i].Scheme = "http"
108 111
 			}
109
-
110
-			out = append(out, *hosts)
111
-
112
-			return out, nil
113
-		},
114
-		docker.ConfigureDefaultRegistries(
115
-			docker.WithClient(newDefaultClient()),
116
-			docker.WithPlainHTTP(docker.MatchLocalhost),
117
-		),
118
-	)
119
-}
120
-
121
-func fillInsecureOpts(host string, c config.RegistryConfig, h docker.RegistryHost) (*docker.RegistryHost, error) {
122
-	tc, err := loadTLSConfig(c)
123
-	if err != nil {
124
-		return nil, err
125
-	}
126
-	var isHTTP bool
127
-
128
-	if c.PlainHTTP != nil && *c.PlainHTTP {
129
-		isHTTP = true
130
-	}
131
-	if c.PlainHTTP == nil {
132
-		if ok, _ := docker.MatchLocalhost(host); ok {
133
-			isHTTP = true
134 112
 		}
135 113
 	}
114
+	return hosts, nil
115
+}
136 116
 
137
-	httpsTransport := newDefaultTransport()
138
-	httpsTransport.TLSClientConfig = tc
139
-
140
-	if c.Insecure != nil && *c.Insecure {
141
-		h2 := h
142
-
143
-		var transport http.RoundTripper = httpsTransport
144
-		if isHTTP {
145
-			transport = &httpFallback{super: transport}
146
-		}
147
-		h2.Client = &http.Client{
148
-			Transport: tracing.NewTransport(transport),
149
-		}
150
-		tc.InsecureSkipVerify = true
151
-		return &h2, nil
152
-	} else if isHTTP {
153
-		h2 := h
154
-		h2.Scheme = "http"
155
-		return &h2, nil
117
+func loadTLSConfig(d string) (*tls.Config, error) {
118
+	fs, err := os.ReadDir(d)
119
+	if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, os.ErrPermission) {
120
+		return nil, errors.WithStack(err)
156 121
 	}
157
-
158
-	h.Client = &http.Client{
159
-		Transport: tracing.NewTransport(httpsTransport),
122
+	type keyPair struct {
123
+		Certificate string
124
+		Key         string
160 125
 	}
161
-	return &h, nil
162
-}
163
-
164
-func loadTLSConfig(c config.RegistryConfig) (*tls.Config, error) {
165
-	for _, d := range c.TLSConfigDir {
166
-		fs, err := os.ReadDir(d)
167
-		if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, os.ErrPermission) {
168
-			return nil, errors.WithStack(err)
126
+	var (
127
+		rootCAs  []string
128
+		keyPairs []keyPair
129
+	)
130
+	for _, f := range fs {
131
+		if strings.HasSuffix(f.Name(), ".crt") {
132
+			rootCAs = append(rootCAs, filepath.Join(d, f.Name()))
169 133
 		}
170
-		for _, f := range fs {
171
-			if strings.HasSuffix(f.Name(), ".crt") {
172
-				c.RootCAs = append(c.RootCAs, filepath.Join(d, f.Name()))
173
-			}
174
-			if strings.HasSuffix(f.Name(), ".cert") {
175
-				c.KeyPairs = append(c.KeyPairs, config.TLSKeyPair{
176
-					Certificate: filepath.Join(d, f.Name()),
177
-					Key:         filepath.Join(d, strings.TrimSuffix(f.Name(), ".cert")+".key"),
178
-				})
179
-			}
134
+		if strings.HasSuffix(f.Name(), ".cert") {
135
+			keyPairs = append(keyPairs, keyPair{
136
+				Certificate: filepath.Join(d, f.Name()),
137
+				Key:         filepath.Join(d, strings.TrimSuffix(f.Name(), ".cert")+".key"),
138
+			})
180 139
 		}
181 140
 	}
182 141
 
183
-	tc := &tls.Config{}
184
-	if len(c.RootCAs) > 0 {
142
+	tc := &tls.Config{
143
+		MinVersion: tls.VersionTLS12,
144
+	}
145
+	if len(rootCAs) > 0 {
185 146
 		systemPool, err := x509.SystemCertPool()
186 147
 		if err != nil {
187 148
 			if runtime.GOOS == "windows" {
... ...
@@ -193,7 +152,7 @@ func loadTLSConfig(c config.RegistryConfig) (*tls.Config, error) {
193 193
 		tc.RootCAs = systemPool
194 194
 	}
195 195
 
196
-	for _, p := range c.RootCAs {
196
+	for _, p := range rootCAs {
197 197
 		dt, err := os.ReadFile(p)
198 198
 		if err != nil {
199 199
 			return nil, errors.Wrapf(err, "failed to read %s", p)
... ...
@@ -201,7 +160,7 @@ func loadTLSConfig(c config.RegistryConfig) (*tls.Config, error) {
201 201
 		tc.RootCAs.AppendCertsFromPEM(dt)
202 202
 	}
203 203
 
204
-	for _, kp := range c.KeyPairs {
204
+	for _, kp := range keyPairs {
205 205
 		cert, err := tls.LoadX509KeyPair(kp.Certificate, kp.Key)
206 206
 		if err != nil {
207 207
 			return nil, errors.Wrapf(err, "failed to load keypair for %s", kp.Certificate)
... ...
@@ -210,87 +169,3 @@ func loadTLSConfig(c config.RegistryConfig) (*tls.Config, error) {
210 210
 	}
211 211
 	return tc, nil
212 212
 }
213
-
214
-func newMirrorRegistryHost(mirror string) docker.RegistryHost {
215
-	mirrorHost, mirrorPath := extractMirrorHostAndPath(mirror)
216
-	path := path.Join(defaultPath, mirrorPath)
217
-	h := docker.RegistryHost{
218
-		Scheme:       "https",
219
-		Client:       newDefaultClient(),
220
-		Host:         mirrorHost,
221
-		Path:         path,
222
-		Capabilities: docker.HostCapabilityPull | docker.HostCapabilityResolve,
223
-	}
224
-
225
-	return h
226
-}
227
-
228
-func newDefaultClient() *http.Client {
229
-	return &http.Client{
230
-		Transport: tracing.NewTransport(newDefaultTransport()),
231
-	}
232
-}
233
-
234
-// newDefaultTransport is for pull or push client
235
-//
236
-// NOTE: For push, there must disable http2 for https because the flow control
237
-// will limit data transfer. The net/http package doesn't provide http2 tunable
238
-// settings which limits push performance.
239
-//
240
-// REF: https://github.com/golang/go/issues/14077
241
-func newDefaultTransport() *http.Transport {
242
-	return &http.Transport{
243
-		Proxy: http.ProxyFromEnvironment,
244
-		DialContext: (&net.Dialer{
245
-			Timeout:   30 * time.Second,
246
-			KeepAlive: 60 * time.Second,
247
-		}).DialContext,
248
-		MaxIdleConns:          30,
249
-		IdleConnTimeout:       120 * time.Second,
250
-		MaxIdleConnsPerHost:   4,
251
-		TLSHandshakeTimeout:   10 * time.Second,
252
-		ExpectContinueTimeout: 5 * time.Second,
253
-		TLSNextProto:          make(map[string]func(authority string, c *tls.Conn) http.RoundTripper),
254
-	}
255
-}
256
-
257
-type httpFallback struct {
258
-	super http.RoundTripper
259
-	host  string
260
-}
261
-
262
-func (f *httpFallback) RoundTrip(r *http.Request) (*http.Response, error) {
263
-	// only fall back if the same host had previously fell back
264
-	if f.host == r.URL.Host {
265
-		resp, err := f.super.RoundTrip(r)
266
-		var tlsErr tls.RecordHeaderError
267
-		if errors.As(err, &tlsErr) && string(tlsErr.RecordHeader[:]) == "HTTP/" {
268
-			f.host = r.URL.Host
269
-		} else {
270
-			return resp, err
271
-		}
272
-	}
273
-
274
-	plainHTTPUrl := *r.URL
275
-	plainHTTPUrl.Scheme = "http"
276
-
277
-	plainHTTPRequest := *r
278
-	plainHTTPRequest.URL = &plainHTTPUrl
279
-
280
-	return f.super.RoundTrip(&plainHTTPRequest)
281
-}
282
-
283
-func extractMirrorHostAndPath(mirror string) (string, string) {
284
-	var path string
285
-	host := mirror
286
-
287
-	u, err := url.Parse(mirror)
288
-	if err != nil || u.Host == "" {
289
-		u, err = url.Parse(fmt.Sprintf("//%s", mirror))
290
-	}
291
-	if err != nil || u.Host == "" {
292
-		return host, path
293
-	}
294
-
295
-	return u.Host, strings.TrimRight(u.Path, "/")
296
-}
297 213
new file mode 100644
... ...
@@ -0,0 +1,42 @@
0
+//go:build !windows
1
+
2
+/*
3
+   Copyright The containerd Authors.
4
+
5
+   Licensed under the Apache License, Version 2.0 (the "License");
6
+   you may not use this file except in compliance with the License.
7
+   You may obtain a copy of the License at
8
+
9
+       http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+   Unless required by applicable law or agreed to in writing, software
12
+   distributed under the License is distributed on an "AS IS" BASIS,
13
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+   See the License for the specific language governing permissions and
15
+   limitations under the License.
16
+*/
17
+
18
+package config
19
+
20
+import (
21
+	"crypto/x509"
22
+	"path/filepath"
23
+)
24
+
25
+func hostPaths(root, host string) (hosts []string) {
26
+	ch := hostDirectory(host)
27
+	if ch != host {
28
+		hosts = append(hosts, filepath.Join(root, ch))
29
+	}
30
+
31
+	hosts = append(hosts,
32
+		filepath.Join(root, host),
33
+		filepath.Join(root, "_default"),
34
+	)
35
+
36
+	return
37
+}
38
+
39
+func rootSystemPool() (*x509.CertPool, error) {
40
+	return x509.SystemCertPool()
41
+}
0 42
new file mode 100644
... ...
@@ -0,0 +1,41 @@
0
+/*
1
+   Copyright The containerd Authors.
2
+
3
+   Licensed under the Apache License, Version 2.0 (the "License");
4
+   you may not use this file except in compliance with the License.
5
+   You may obtain a copy of the License at
6
+
7
+       http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+   Unless required by applicable law or agreed to in writing, software
10
+   distributed under the License is distributed on an "AS IS" BASIS,
11
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+   See the License for the specific language governing permissions and
13
+   limitations under the License.
14
+*/
15
+
16
+package config
17
+
18
+import (
19
+	"crypto/x509"
20
+	"path/filepath"
21
+	"strings"
22
+)
23
+
24
+func hostPaths(root, host string) (hosts []string) {
25
+	ch := hostDirectory(host)
26
+	if ch != host {
27
+		hosts = append(hosts, filepath.Join(root, strings.Replace(ch, ":", "", -1)))
28
+	}
29
+
30
+	hosts = append(hosts,
31
+		filepath.Join(root, strings.Replace(host, ":", "", -1)),
32
+		filepath.Join(root, "_default"),
33
+	)
34
+
35
+	return
36
+}
37
+
38
+func rootSystemPool() (*x509.CertPool, error) {
39
+	return x509.NewCertPool(), nil
40
+}
0 41
new file mode 100644
... ...
@@ -0,0 +1,44 @@
0
+//go:build gofuzz
1
+
2
+/*
3
+   Copyright The containerd Authors.
4
+
5
+   Licensed under the Apache License, Version 2.0 (the "License");
6
+   you may not use this file except in compliance with the License.
7
+   You may obtain a copy of the License at
8
+
9
+       http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+   Unless required by applicable law or agreed to in writing, software
12
+   distributed under the License is distributed on an "AS IS" BASIS,
13
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+   See the License for the specific language governing permissions and
15
+   limitations under the License.
16
+*/
17
+
18
+package config
19
+
20
+import (
21
+	"os"
22
+
23
+	fuzz "github.com/AdaLogics/go-fuzz-headers"
24
+)
25
+
26
+func FuzzParseHostsFile(data []byte) int {
27
+	f := fuzz.NewConsumer(data)
28
+	dir, err := os.MkdirTemp("", "fuzz-")
29
+	if err != nil {
30
+		return 0
31
+	}
32
+	err = f.CreateFiles(dir)
33
+	if err != nil {
34
+		return 0
35
+	}
36
+	defer os.RemoveAll(dir)
37
+	b, err := f.GetBytes()
38
+	if err != nil {
39
+		return 0
40
+	}
41
+	_, _ = parseHostsFile(dir, b)
42
+	return 1
43
+}
0 44
new file mode 100644
... ...
@@ -0,0 +1,612 @@
0
+/*
1
+   Copyright The containerd Authors.
2
+
3
+   Licensed under the Apache License, Version 2.0 (the "License");
4
+   you may not use this file except in compliance with the License.
5
+   You may obtain a copy of the License at
6
+
7
+       http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+   Unless required by applicable law or agreed to in writing, software
10
+   distributed under the License is distributed on an "AS IS" BASIS,
11
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+   See the License for the specific language governing permissions and
13
+   limitations under the License.
14
+*/
15
+
16
+// Package config contains utilities for helping configure the Docker resolver
17
+package config
18
+
19
+import (
20
+	"context"
21
+	"crypto/tls"
22
+	"errors"
23
+	"fmt"
24
+	"net"
25
+	"net/http"
26
+	"net/url"
27
+	"os"
28
+	"path"
29
+	"path/filepath"
30
+	"sort"
31
+	"strings"
32
+	"time"
33
+
34
+	"github.com/containerd/containerd/remotes/docker"
35
+	"github.com/containerd/errdefs"
36
+	"github.com/containerd/log"
37
+	"github.com/pelletier/go-toml"
38
+)
39
+
40
+// UpdateClientFunc is a function that lets you to amend http Client behavior used by registry clients.
41
+type UpdateClientFunc func(client *http.Client) error
42
+
43
+type hostConfig struct {
44
+	scheme string
45
+	host   string
46
+	path   string
47
+
48
+	capabilities docker.HostCapabilities
49
+
50
+	caCerts     []string
51
+	clientPairs [][2]string
52
+	skipVerify  *bool
53
+
54
+	header http.Header
55
+
56
+	// TODO: Add credential configuration (domain alias, username)
57
+}
58
+
59
+// HostOptions is used to configure registry hosts
60
+type HostOptions struct {
61
+	HostDir       func(string) (string, error)
62
+	Credentials   func(host string) (string, string, error)
63
+	DefaultTLS    *tls.Config
64
+	DefaultScheme string
65
+	// UpdateClient will be called after creating http.Client object, so clients can provide extra configuration
66
+	UpdateClient   UpdateClientFunc
67
+	AuthorizerOpts []docker.AuthorizerOpt
68
+}
69
+
70
+// ConfigureHosts creates a registry hosts function from the provided
71
+// host creation options. The host directory can read hosts.toml or
72
+// certificate files laid out in the Docker specific layout.
73
+// If a `HostDir` function is not required, defaults are used.
74
+func ConfigureHosts(ctx context.Context, options HostOptions) docker.RegistryHosts {
75
+	return func(host string) ([]docker.RegistryHost, error) {
76
+		var hosts []hostConfig
77
+		if options.HostDir != nil {
78
+			dir, err := options.HostDir(host)
79
+			if err != nil && !errdefs.IsNotFound(err) {
80
+				return nil, err
81
+			}
82
+			if dir != "" {
83
+				log.G(ctx).WithField("dir", dir).Debug("loading host directory")
84
+				hosts, err = loadHostDir(ctx, dir)
85
+				if err != nil {
86
+					return nil, err
87
+				}
88
+			}
89
+		}
90
+
91
+		// If hosts was not set, add a default host
92
+		// NOTE: Check nil here and not empty, the host may be
93
+		// intentionally configured to not have any endpoints
94
+		if hosts == nil {
95
+			hosts = make([]hostConfig, 1)
96
+		}
97
+		if len(hosts) > 0 && hosts[len(hosts)-1].host == "" {
98
+			if host == "docker.io" {
99
+				hosts[len(hosts)-1].scheme = "https"
100
+				hosts[len(hosts)-1].host = "registry-1.docker.io"
101
+			} else if docker.IsLocalhost(host) {
102
+				hosts[len(hosts)-1].host = host
103
+				if options.DefaultScheme == "" {
104
+					_, port, _ := net.SplitHostPort(host)
105
+					if port == "" || port == "443" {
106
+						// If port is default or 443, only use https
107
+						hosts[len(hosts)-1].scheme = "https"
108
+					} else {
109
+						// HTTP fallback logic will be used when protocol is ambiguous
110
+						hosts[len(hosts)-1].scheme = "http"
111
+					}
112
+
113
+					// When port is 80, protocol is not ambiguous
114
+					if port != "80" {
115
+						// Skipping TLS verification for localhost
116
+						var skipVerify = true
117
+						hosts[len(hosts)-1].skipVerify = &skipVerify
118
+					}
119
+				} else {
120
+					hosts[len(hosts)-1].scheme = options.DefaultScheme
121
+				}
122
+			} else {
123
+				hosts[len(hosts)-1].host = host
124
+				if options.DefaultScheme != "" {
125
+					hosts[len(hosts)-1].scheme = options.DefaultScheme
126
+				} else {
127
+					hosts[len(hosts)-1].scheme = "https"
128
+				}
129
+			}
130
+			hosts[len(hosts)-1].path = "/v2"
131
+			hosts[len(hosts)-1].capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush
132
+		}
133
+
134
+		// tlsConfigured indicates that TLS was configured and HTTP endpoints should
135
+		// attempt to use the TLS configuration before falling back to HTTP
136
+		var tlsConfigured bool
137
+
138
+		var defaultTLSConfig *tls.Config
139
+		if options.DefaultTLS != nil {
140
+			tlsConfigured = true
141
+			defaultTLSConfig = options.DefaultTLS
142
+		} else {
143
+			defaultTLSConfig = &tls.Config{}
144
+		}
145
+
146
+		defaultTransport := &http.Transport{
147
+			Proxy: http.ProxyFromEnvironment,
148
+			DialContext: (&net.Dialer{
149
+				Timeout:       30 * time.Second,
150
+				KeepAlive:     30 * time.Second,
151
+				FallbackDelay: 300 * time.Millisecond,
152
+			}).DialContext,
153
+			MaxIdleConns:          10,
154
+			IdleConnTimeout:       30 * time.Second,
155
+			TLSHandshakeTimeout:   10 * time.Second,
156
+			TLSClientConfig:       defaultTLSConfig,
157
+			ExpectContinueTimeout: 5 * time.Second,
158
+		}
159
+
160
+		client := &http.Client{
161
+			Transport: defaultTransport,
162
+		}
163
+		if options.UpdateClient != nil {
164
+			if err := options.UpdateClient(client); err != nil {
165
+				return nil, err
166
+			}
167
+		}
168
+
169
+		authOpts := []docker.AuthorizerOpt{docker.WithAuthClient(client)}
170
+		if options.Credentials != nil {
171
+			authOpts = append(authOpts, docker.WithAuthCreds(options.Credentials))
172
+		}
173
+		authOpts = append(authOpts, options.AuthorizerOpts...)
174
+		authorizer := docker.NewDockerAuthorizer(authOpts...)
175
+
176
+		rhosts := make([]docker.RegistryHost, len(hosts))
177
+		for i, host := range hosts {
178
+			// Allow setting for each host as well
179
+			explicitTLS := tlsConfigured
180
+
181
+			if host.caCerts != nil || host.clientPairs != nil || host.skipVerify != nil {
182
+				explicitTLS = true
183
+				tr := defaultTransport.Clone()
184
+				tlsConfig := tr.TLSClientConfig
185
+				if host.skipVerify != nil {
186
+					tlsConfig.InsecureSkipVerify = *host.skipVerify
187
+				}
188
+				if host.caCerts != nil {
189
+					if tlsConfig.RootCAs == nil {
190
+						rootPool, err := rootSystemPool()
191
+						if err != nil {
192
+							return nil, fmt.Errorf("unable to initialize cert pool: %w", err)
193
+						}
194
+						tlsConfig.RootCAs = rootPool
195
+					}
196
+					for _, f := range host.caCerts {
197
+						data, err := os.ReadFile(f)
198
+						if err != nil {
199
+							return nil, fmt.Errorf("unable to read CA cert %q: %w", f, err)
200
+						}
201
+						if !tlsConfig.RootCAs.AppendCertsFromPEM(data) {
202
+							return nil, fmt.Errorf("unable to load CA cert %q", f)
203
+						}
204
+					}
205
+				}
206
+
207
+				if host.clientPairs != nil {
208
+					for _, pair := range host.clientPairs {
209
+						certPEMBlock, err := os.ReadFile(pair[0])
210
+						if err != nil {
211
+							return nil, fmt.Errorf("unable to read CERT file %q: %w", pair[0], err)
212
+						}
213
+						var keyPEMBlock []byte
214
+						if pair[1] != "" {
215
+							keyPEMBlock, err = os.ReadFile(pair[1])
216
+							if err != nil {
217
+								return nil, fmt.Errorf("unable to read CERT file %q: %w", pair[1], err)
218
+							}
219
+						} else {
220
+							// Load key block from same PEM file
221
+							keyPEMBlock = certPEMBlock
222
+						}
223
+						cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
224
+						if err != nil {
225
+							return nil, fmt.Errorf("failed to load X509 key pair: %w", err)
226
+						}
227
+
228
+						tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
229
+					}
230
+				}
231
+
232
+				c := *client
233
+				c.Transport = tr
234
+				if options.UpdateClient != nil {
235
+					if err := options.UpdateClient(&c); err != nil {
236
+						return nil, err
237
+					}
238
+				}
239
+
240
+				rhosts[i].Client = &c
241
+				rhosts[i].Authorizer = docker.NewDockerAuthorizer(append(authOpts, docker.WithAuthClient(&c))...)
242
+			} else {
243
+				rhosts[i].Client = client
244
+				rhosts[i].Authorizer = authorizer
245
+			}
246
+
247
+			// When TLS has been configured for the operation or host and
248
+			// the protocol from the port number is ambiguous, use the
249
+			// docker.NewHTTPFallback roundtripper to catch TLS errors and re-attempt the
250
+			// request as http. This allows preference for https when configured but
251
+			// also catches TLS errors early enough in the request to avoid sending
252
+			// the request twice or consuming the request body.
253
+			if host.scheme == "http" && explicitTLS {
254
+				_, port, _ := net.SplitHostPort(host.host)
255
+				if port != "" && port != "80" {
256
+					log.G(ctx).WithField("host", host.host).Info("host will try HTTPS first since it is configured for HTTP with a TLS configuration, consider changing host to HTTPS or removing unused TLS configuration")
257
+					host.scheme = "https"
258
+					rhosts[i].Client.Transport = docker.NewHTTPFallback(rhosts[i].Client.Transport)
259
+				}
260
+			}
261
+
262
+			rhosts[i].Scheme = host.scheme
263
+			rhosts[i].Host = host.host
264
+			rhosts[i].Path = host.path
265
+			rhosts[i].Capabilities = host.capabilities
266
+			rhosts[i].Header = host.header
267
+		}
268
+
269
+		return rhosts, nil
270
+	}
271
+
272
+}
273
+
274
+// HostDirFromRoot returns a function which finds a host directory
275
+// based at the given root.
276
+func HostDirFromRoot(root string) func(string) (string, error) {
277
+	return func(host string) (string, error) {
278
+		for _, p := range hostPaths(root, host) {
279
+			if _, err := os.Stat(p); err == nil {
280
+				return p, nil
281
+			} else if !os.IsNotExist(err) {
282
+				return "", err
283
+			}
284
+		}
285
+		return "", errdefs.ErrNotFound
286
+	}
287
+}
288
+
289
+// hostDirectory converts ":port" to "_port_" in directory names
290
+func hostDirectory(host string) string {
291
+	idx := strings.LastIndex(host, ":")
292
+	if idx > 0 {
293
+		return host[:idx] + "_" + host[idx+1:] + "_"
294
+	}
295
+	return host
296
+}
297
+
298
+func loadHostDir(ctx context.Context, hostsDir string) ([]hostConfig, error) {
299
+	b, err := os.ReadFile(filepath.Join(hostsDir, "hosts.toml"))
300
+	if err != nil && !os.IsNotExist(err) {
301
+		return nil, err
302
+	}
303
+
304
+	if len(b) == 0 {
305
+		// If hosts.toml does not exist, fallback to checking for
306
+		// certificate files based on Docker's certificate file
307
+		// pattern (".crt", ".cert", ".key" files)
308
+		return loadCertFiles(ctx, hostsDir)
309
+	}
310
+
311
+	hosts, err := parseHostsFile(hostsDir, b)
312
+	if err != nil {
313
+		log.G(ctx).WithError(err).Error("failed to decode hosts.toml")
314
+		// Fallback to checking certificate files
315
+		return loadCertFiles(ctx, hostsDir)
316
+	}
317
+
318
+	return hosts, nil
319
+}
320
+
321
+type hostFileConfig struct {
322
+	// Capabilities determine what operations a host is
323
+	// capable of performing. Allowed values
324
+	//  - pull
325
+	//  - resolve
326
+	//  - push
327
+	Capabilities []string `toml:"capabilities"`
328
+
329
+	// CACert are the public key certificates for TLS
330
+	// Accepted types
331
+	// - string - Single file with certificate(s)
332
+	// - []string - Multiple files with certificates
333
+	CACert interface{} `toml:"ca"`
334
+
335
+	// Client keypair(s) for TLS with client authentication
336
+	// Accepted types
337
+	// - string - Single file with public and private keys
338
+	// - []string - Multiple files with public and private keys
339
+	// - [][2]string - Multiple keypairs with public and private keys in separate files
340
+	Client interface{} `toml:"client"`
341
+
342
+	// SkipVerify skips verification of the server's certificate chain
343
+	// and host name. This should only be used for testing or in
344
+	// combination with other methods of verifying connections.
345
+	SkipVerify *bool `toml:"skip_verify"`
346
+
347
+	// Header are additional header files to send to the server
348
+	Header map[string]interface{} `toml:"header"`
349
+
350
+	// OverridePath indicates the API root endpoint is defined in the URL
351
+	// path rather than by the API specification.
352
+	// This may be used with non-compliant OCI registries to override the
353
+	// API root endpoint.
354
+	OverridePath bool `toml:"override_path"`
355
+
356
+	// TODO: Credentials: helper? name? username? alternate domain? token?
357
+}
358
+
359
+func parseHostsFile(baseDir string, b []byte) ([]hostConfig, error) {
360
+	tree, err := toml.LoadBytes(b)
361
+	if err != nil {
362
+		return nil, fmt.Errorf("failed to parse TOML: %w", err)
363
+	}
364
+
365
+	// HACK: we want to keep toml parsing structures private in this package, however go-toml ignores private embedded types.
366
+	// so we remap it to a public type within the func body, so technically it's public, but not possible to import elsewhere.
367
+	type HostFileConfig = hostFileConfig
368
+
369
+	c := struct {
370
+		HostFileConfig
371
+		// Server specifies the default server. When `host` is
372
+		// also specified, those hosts are tried first.
373
+		Server string `toml:"server"`
374
+		// HostConfigs store the per-host configuration
375
+		HostConfigs map[string]hostFileConfig `toml:"host"`
376
+	}{}
377
+
378
+	orderedHosts, err := getSortedHosts(tree)
379
+	if err != nil {
380
+		return nil, err
381
+	}
382
+
383
+	var (
384
+		hosts []hostConfig
385
+	)
386
+
387
+	if err := tree.Unmarshal(&c); err != nil {
388
+		return nil, err
389
+	}
390
+
391
+	// Parse hosts array
392
+	for _, host := range orderedHosts {
393
+		config := c.HostConfigs[host]
394
+
395
+		parsed, err := parseHostConfig(host, baseDir, config)
396
+		if err != nil {
397
+			return nil, err
398
+		}
399
+		hosts = append(hosts, parsed)
400
+	}
401
+
402
+	// Parse root host config and append it as the last element
403
+	parsed, err := parseHostConfig(c.Server, baseDir, c.HostFileConfig)
404
+	if err != nil {
405
+		return nil, err
406
+	}
407
+	hosts = append(hosts, parsed)
408
+
409
+	return hosts, nil
410
+}
411
+
412
+func parseHostConfig(server string, baseDir string, config hostFileConfig) (hostConfig, error) {
413
+	var (
414
+		result = hostConfig{}
415
+		err    error
416
+	)
417
+
418
+	if server != "" {
419
+		if !strings.HasPrefix(server, "http") {
420
+			server = "https://" + server
421
+		}
422
+		u, err := url.Parse(server)
423
+		if err != nil {
424
+			return hostConfig{}, fmt.Errorf("unable to parse server %v: %w", server, err)
425
+		}
426
+		result.scheme = u.Scheme
427
+		result.host = u.Host
428
+		if len(u.Path) > 0 {
429
+			u.Path = path.Clean(u.Path)
430
+			if !strings.HasSuffix(u.Path, "/v2") && !config.OverridePath {
431
+				u.Path = u.Path + "/v2"
432
+			}
433
+		} else if !config.OverridePath {
434
+			u.Path = "/v2"
435
+		}
436
+		result.path = u.Path
437
+	}
438
+
439
+	result.skipVerify = config.SkipVerify
440
+
441
+	if len(config.Capabilities) > 0 {
442
+		for _, c := range config.Capabilities {
443
+			switch strings.ToLower(c) {
444
+			case "pull":
445
+				result.capabilities |= docker.HostCapabilityPull
446
+			case "resolve":
447
+				result.capabilities |= docker.HostCapabilityResolve
448
+			case "push":
449
+				result.capabilities |= docker.HostCapabilityPush
450
+			default:
451
+				return hostConfig{}, fmt.Errorf("unknown capability %v", c)
452
+			}
453
+		}
454
+	} else {
455
+		result.capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush
456
+	}
457
+
458
+	if config.CACert != nil {
459
+		switch cert := config.CACert.(type) {
460
+		case string:
461
+			result.caCerts = []string{makeAbsPath(cert, baseDir)}
462
+		case []interface{}:
463
+			result.caCerts, err = makeStringSlice(cert, func(p string) string {
464
+				return makeAbsPath(p, baseDir)
465
+			})
466
+			if err != nil {
467
+				return hostConfig{}, err
468
+			}
469
+		default:
470
+			return hostConfig{}, fmt.Errorf("invalid type %v for \"ca\"", cert)
471
+		}
472
+	}
473
+
474
+	if config.Client != nil {
475
+		switch client := config.Client.(type) {
476
+		case string:
477
+			result.clientPairs = [][2]string{{makeAbsPath(client, baseDir), ""}}
478
+		case []interface{}:
479
+			// []string or [][2]string
480
+			for _, pairs := range client {
481
+				switch p := pairs.(type) {
482
+				case string:
483
+					result.clientPairs = append(result.clientPairs, [2]string{makeAbsPath(p, baseDir), ""})
484
+				case []interface{}:
485
+					slice, err := makeStringSlice(p, func(s string) string {
486
+						return makeAbsPath(s, baseDir)
487
+					})
488
+					if err != nil {
489
+						return hostConfig{}, err
490
+					}
491
+					if len(slice) != 2 {
492
+						return hostConfig{}, fmt.Errorf("invalid pair %v for \"client\"", p)
493
+					}
494
+
495
+					var pair [2]string
496
+					copy(pair[:], slice)
497
+					result.clientPairs = append(result.clientPairs, pair)
498
+				default:
499
+					return hostConfig{}, fmt.Errorf("invalid type %T for \"client\"", p)
500
+				}
501
+			}
502
+		default:
503
+			return hostConfig{}, fmt.Errorf("invalid type %v for \"client\"", client)
504
+		}
505
+	}
506
+
507
+	if config.Header != nil {
508
+		header := http.Header{}
509
+		for key, ty := range config.Header {
510
+			switch value := ty.(type) {
511
+			case string:
512
+				header[key] = []string{value}
513
+			case []interface{}:
514
+				header[key], err = makeStringSlice(value, nil)
515
+				if err != nil {
516
+					return hostConfig{}, err
517
+				}
518
+			default:
519
+				return hostConfig{}, fmt.Errorf("invalid type %v for header %q", ty, key)
520
+			}
521
+		}
522
+		result.header = header
523
+	}
524
+
525
+	return result, nil
526
+}
527
+
528
+// getSortedHosts returns the list of hosts as they defined in the file.
529
+func getSortedHosts(root *toml.Tree) ([]string, error) {
530
+	iter, ok := root.Get("host").(*toml.Tree)
531
+	if !ok {
532
+		return nil, errors.New("invalid `host` tree")
533
+	}
534
+
535
+	list := append([]string{}, iter.Keys()...)
536
+
537
+	// go-toml stores TOML sections in the map object, so no order guaranteed.
538
+	// We retrieve line number for each key and sort the keys by position.
539
+	sort.Slice(list, func(i, j int) bool {
540
+		h1 := iter.GetPath([]string{list[i]}).(*toml.Tree)
541
+		h2 := iter.GetPath([]string{list[j]}).(*toml.Tree)
542
+		return h1.Position().Line < h2.Position().Line
543
+	})
544
+
545
+	return list, nil
546
+}
547
+
548
+// makeStringSlice is a helper func to convert from []interface{} to []string.
549
+// Additionally an optional cb func may be passed to perform string mapping.
550
+func makeStringSlice(slice []interface{}, cb func(string) string) ([]string, error) {
551
+	out := make([]string, len(slice))
552
+	for i, value := range slice {
553
+		str, ok := value.(string)
554
+		if !ok {
555
+			return nil, fmt.Errorf("unable to cast %v to string", value)
556
+		}
557
+
558
+		if cb != nil {
559
+			out[i] = cb(str)
560
+		} else {
561
+			out[i] = str
562
+		}
563
+	}
564
+	return out, nil
565
+}
566
+
567
+func makeAbsPath(p string, base string) string {
568
+	if filepath.IsAbs(p) {
569
+		return p
570
+	}
571
+	return filepath.Join(base, p)
572
+}
573
+
574
+// loadCertsDir loads certs from certsDir like "/etc/docker/certs.d" .
575
+// Compatible with Docker file layout
576
+//   - files ending with ".crt" are treated as CA certificate files
577
+//   - files ending with ".cert" are treated as client certificates, and
578
+//     files with the same name but ending with ".key" are treated as the
579
+//     corresponding private key.
580
+//     NOTE: If a ".key" file is missing, this function will just return
581
+//     the ".cert", which may contain the private key. If the ".cert" file
582
+//     does not contain the private key, the caller should detect and error.
583
+func loadCertFiles(ctx context.Context, certsDir string) ([]hostConfig, error) {
584
+	fs, err := os.ReadDir(certsDir)
585
+	if err != nil && !os.IsNotExist(err) {
586
+		return nil, err
587
+	}
588
+	hosts := make([]hostConfig, 1)
589
+	for _, f := range fs {
590
+		if f.IsDir() {
591
+			continue
592
+		}
593
+		if strings.HasSuffix(f.Name(), ".crt") {
594
+			hosts[0].caCerts = append(hosts[0].caCerts, filepath.Join(certsDir, f.Name()))
595
+		}
596
+		if strings.HasSuffix(f.Name(), ".cert") {
597
+			var pair [2]string
598
+			certFile := f.Name()
599
+			pair[0] = filepath.Join(certsDir, certFile)
600
+			// Check if key also exists
601
+			keyFile := filepath.Join(certsDir, certFile[:len(certFile)-5]+".key")
602
+			if _, err := os.Stat(keyFile); err == nil {
603
+				pair[1] = keyFile
604
+			} else if !os.IsNotExist(err) {
605
+				return nil, err
606
+			}
607
+			hosts[0].clientPairs = append(hosts[0].clientPairs, pair)
608
+		}
609
+	}
610
+	return hosts, nil
611
+}
... ...
@@ -312,6 +312,7 @@ github.com/containerd/containerd/reference
312 312
 github.com/containerd/containerd/remotes
313 313
 github.com/containerd/containerd/remotes/docker
314 314
 github.com/containerd/containerd/remotes/docker/auth
315
+github.com/containerd/containerd/remotes/docker/config
315 316
 github.com/containerd/containerd/remotes/docker/schema1
316 317
 github.com/containerd/containerd/remotes/errors
317 318
 github.com/containerd/containerd/rootfs