diff --git a/daemon/containerd/resolver.go b/daemon/containerd/resolver.go index 191daa7f87..92f4304367 100644 --- a/daemon/containerd/resolver.go +++ b/daemon/containerd/resolver.go @@ -2,8 +2,6 @@ package containerd import ( "context" - "crypto/tls" - "errors" "net/http" "github.com/containerd/containerd/remotes" @@ -33,11 +31,12 @@ func (i *ImageService) newResolverFromAuthConfig(ctx context.Context, authConfig } func hostsWrapper(hostsFn docker.RegistryHosts, optAuthConfig *registrytypes.AuthConfig, ref reference.Named, regService registryResolver) docker.RegistryHosts { - var authorizer docker.Authorizer - if optAuthConfig != nil { - authorizer = authorizerFromAuthConfig(*optAuthConfig, ref) + if optAuthConfig == nil { + return hostsFn } + authorizer := authorizerFromAuthConfig(*optAuthConfig, ref) + return func(n string) ([]docker.RegistryHost, error) { hosts, err := hostsFn(n) if err != nil { @@ -45,13 +44,7 @@ func hostsWrapper(hostsFn docker.RegistryHosts, optAuthConfig *registrytypes.Aut } for i := range hosts { - if hosts[i].Authorizer == nil { - hosts[i].Authorizer = authorizer - isInsecure := regService.IsInsecureRegistry(hosts[i].Host) - if hosts[i].Client.Transport != nil && isInsecure { - hosts[i].Client.Transport = httpFallback{super: hosts[i].Client.Transport} - } - } + hosts[i].Authorizer = authorizer } return hosts, nil } @@ -111,24 +104,3 @@ func (a *bearerAuthorizer) AddResponses(context.Context, []*http.Response) error // Return not implemented to prevent retry of the request when bearer did not succeed return cerrdefs.ErrNotImplemented } - -type httpFallback struct { - super http.RoundTripper -} - -func (f httpFallback) RoundTrip(r *http.Request) (*http.Response, error) { - resp, err := f.super.RoundTrip(r) - var tlsErr tls.RecordHeaderError - if errors.As(err, &tlsErr) && string(tlsErr.RecordHeader[:]) == "HTTP/" { - // server gave HTTP response to HTTPS client - plainHttpUrl := *r.URL - plainHttpUrl.Scheme = "http" - - plainHttpRequest := *r - plainHttpRequest.URL = &plainHttpUrl - - return http.DefaultTransport.RoundTrip(&plainHttpRequest) - } - - return resp, err -} diff --git a/daemon/daemon.go b/daemon/daemon.go index 7d80f46c02..7e719a6115 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -27,7 +27,6 @@ import ( "github.com/containerd/containerd" "github.com/containerd/containerd/defaults" "github.com/containerd/containerd/pkg/dialer" - "github.com/containerd/containerd/remotes/docker" "github.com/containerd/log" "github.com/distribution/reference" dist "github.com/docker/distribution" @@ -75,8 +74,6 @@ import ( "github.com/docker/docker/registry" volumesservice "github.com/docker/docker/volume/service" "github.com/moby/buildkit/util/grpcerrors" - "github.com/moby/buildkit/util/resolver" - resolverconfig "github.com/moby/buildkit/util/resolver/config" "github.com/moby/buildkit/util/tracing" "github.com/moby/locker" "github.com/moby/sys/userns" @@ -207,48 +204,6 @@ func (daemon *Daemon) UsesSnapshotter() bool { return daemon.usesSnapshotter } -// RegistryHosts returns the registry hosts configuration for the host component -// of a distribution image reference. -func (daemon *Daemon) RegistryHosts(host string) ([]docker.RegistryHost, error) { - m := map[string]resolverconfig.RegistryConfig{ - "docker.io": {Mirrors: daemon.registryService.ServiceConfig().Mirrors}, - } - conf := daemon.registryService.ServiceConfig().IndexConfigs - for k, v := range conf { - c := m[k] - if !v.Secure { - t := true - c.PlainHTTP = &t - c.Insecure = &t - } - m[k] = c - } - if c, ok := m[host]; !ok && daemon.registryService.IsInsecureRegistry(host) { - t := true - c.PlainHTTP = &t - c.Insecure = &t - m[host] = c - } - - for k, v := range m { - v.TLSConfigDir = []string{registry.HostCertsDir(k)} - m[k] = v - } - - certsDir := registry.CertsDir() - if fis, err := os.ReadDir(certsDir); err == nil { - for _, fi := range fis { - if _, ok := m[fi.Name()]; !ok { - m[fi.Name()] = resolverconfig.RegistryConfig{ - TLSConfigDir: []string{filepath.Join(certsDir, fi.Name())}, - } - } - } - } - - return resolver.NewRegistryConfig(m)(host) -} - // layerAccessor may be implemented by ImageService type layerAccessor interface { GetLayerByID(cid string) (layer.RWLayer, error) diff --git a/daemon/hosts.go b/daemon/hosts.go new file mode 100644 index 0000000000..3e34ef4da9 --- /dev/null +++ b/daemon/hosts.go @@ -0,0 +1,174 @@ +package daemon // import "github.com/docker/docker/daemon" + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "runtime" + "strings" + + "github.com/containerd/containerd/remotes/docker" + hostconfig "github.com/containerd/containerd/remotes/docker/config" + cerrdefs "github.com/containerd/errdefs" + "github.com/docker/docker/registry" + "github.com/pkg/errors" +) + +const ( + defaultPath = "/v2" +) + +// RegistryHosts returns the registry hosts configuration for the host component +// of a distribution image reference. +func (daemon *Daemon) RegistryHosts(host string) ([]docker.RegistryHost, error) { + hosts, err := hostconfig.ConfigureHosts(context.Background(), hostconfig.HostOptions{ + // TODO: Also check containerd path when updating containerd to use multiple host directories + HostDir: hostconfig.HostDirFromRoot(registry.CertsDir()), + })(host) + if err == nil { + // Merge in legacy configuration if provided + if cfg := daemon.Config(); len(cfg.Mirrors) > 0 || len(cfg.InsecureRegistries) > 0 { + hosts, err = daemon.mergeLegacyConfig(host, hosts) + } + } + + return hosts, err +} + +func (daemon *Daemon) mergeLegacyConfig(host string, hosts []docker.RegistryHost) ([]docker.RegistryHost, error) { + // If no hosts provided, nothing to do. + // If multiple hosts provided, then a mirror configuration is already provided and + // should not overwrite with legacy config. + if len(hosts) == 0 || len(hosts) > 1 { + return hosts, nil + } + sc := daemon.registryService.ServiceConfig() + if host == "docker.io" && len(sc.Mirrors) > 0 { + hosts = mirrorsToRegistryHosts(sc.Mirrors, hosts[0]) + } + hostDir := hostconfig.HostDirFromRoot(registry.CertsDir()) + for i := range hosts { + t, ok := hosts[i].Client.Transport.(*http.Transport) + if !ok { + continue + } + if t.TLSClientConfig == nil { + certsDir, err := hostDir(host) + if err != nil && !cerrdefs.IsNotFound(err) { + return nil, err + } else if err == nil { + c, err := loadTLSConfig(certsDir) + if err != nil { + return nil, err + } + t.TLSClientConfig = c + } + } + if daemon.registryService.IsInsecureRegistry(hosts[i].Host) { + if t.TLSClientConfig == nil { + t.TLSClientConfig = &tls.Config{} //nolint: gosec // G402: TLS MinVersion too low. + } + t.TLSClientConfig.InsecureSkipVerify = true + + hosts[i].Client.Transport = docker.NewHTTPFallback(hosts[i].Client.Transport) + } + } + return hosts, nil +} + +func mirrorsToRegistryHosts(mirrors []string, dHost docker.RegistryHost) []docker.RegistryHost { + var mirrorHosts []docker.RegistryHost + for _, mirror := range mirrors { + h := dHost + h.Capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve + + u, err := url.Parse(mirror) + if err != nil || u.Host == "" { + u, err = url.Parse(fmt.Sprintf("dummy://%s", mirror)) + } + if err == nil && u.Host != "" { + h.Host = u.Host + h.Path = strings.TrimSuffix(u.Path, "/") + + // For compatibility with legacy mirrors, ensure ends with /v2 + // NOTE: Use newer configuration to completely override the path + if !strings.HasSuffix(h.Path, defaultPath) { + h.Path = path.Join(h.Path, defaultPath) + } + if u.Scheme != "dummy" { + h.Scheme = u.Scheme + } + } else { + h.Host = mirror + h.Path = defaultPath + } + + mirrorHosts = append(mirrorHosts, h) + } + return append(mirrorHosts, dHost) + +} + +func loadTLSConfig(d string) (*tls.Config, error) { + fs, err := os.ReadDir(d) + if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, os.ErrPermission) { + return nil, errors.WithStack(err) + } + type keyPair struct { + Certificate string + Key string + } + var ( + rootCAs []string + keyPairs []keyPair + ) + for _, f := range fs { + switch filepath.Ext(f.Name()) { + case ".crt": + rootCAs = append(rootCAs, filepath.Join(d, f.Name())) + case ".cert": + keyPairs = append(keyPairs, keyPair{ + Certificate: filepath.Join(d, f.Name()), + Key: filepath.Join(d, strings.TrimSuffix(f.Name(), ".cert")+".key"), + }) + } + } + + tc := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + if len(rootCAs) > 0 { + systemPool, err := x509.SystemCertPool() + if err != nil { + if runtime.GOOS == "windows" { + systemPool = x509.NewCertPool() + } else { + return nil, errors.Wrapf(err, "unable to get system cert pool") + } + } + tc.RootCAs = systemPool + } + + for _, p := range rootCAs { + dt, err := os.ReadFile(p) + if err != nil { + return nil, err + } + tc.RootCAs.AppendCertsFromPEM(dt) + } + + for _, kp := range keyPairs { + cert, err := tls.LoadX509KeyPair(kp.Certificate, kp.Key) + if err != nil { + return nil, errors.Wrapf(err, "failed to load keypair for %s", kp.Certificate) + } + tc.Certificates = append(tc.Certificates, cert) + } + return tc, nil +} diff --git a/daemon/hosts_test.go b/daemon/hosts_test.go new file mode 100644 index 0000000000..98d962e266 --- /dev/null +++ b/daemon/hosts_test.go @@ -0,0 +1,107 @@ +package daemon // import "github.com/docker/docker/daemon" + +import ( + "testing" + + "github.com/containerd/containerd/remotes/docker" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestMirrorsToHosts(t *testing.T) { + pullCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve + allCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush + defaultRegistry := testRegistryHost("https", "registry-1.docker.com", "/v2", allCaps) + for _, tc := range []struct { + mirrors []string + dhost docker.RegistryHost + expected []docker.RegistryHost + }{ + { + mirrors: []string{"https://localhost:5000"}, + dhost: defaultRegistry, + expected: []docker.RegistryHost{ + testRegistryHost("https", "localhost:5000", "/v2", pullCaps), + defaultRegistry, + }, + }, + { + mirrors: []string{"http://localhost:5000"}, + dhost: defaultRegistry, + expected: []docker.RegistryHost{ + testRegistryHost("http", "localhost:5000", "/v2", pullCaps), + defaultRegistry, + }, + }, + { + mirrors: []string{"http://localhost:5000/v2"}, + dhost: defaultRegistry, + expected: []docker.RegistryHost{ + testRegistryHost("http", "localhost:5000", "/v2", pullCaps), + defaultRegistry, + }, + }, + { + mirrors: []string{"localhost:5000"}, + dhost: defaultRegistry, + expected: []docker.RegistryHost{ + testRegistryHost("https", "localhost:5000", "/v2", pullCaps), + defaultRegistry, + }, + }, + { + mirrors: []string{"localhost:5000/trailingslash/"}, + dhost: defaultRegistry, + expected: []docker.RegistryHost{ + testRegistryHost("https", "localhost:5000", "/trailingslash/v2", pullCaps), + defaultRegistry, + }, + }, + { + mirrors: []string{"localhost:5000/2trailingslash//"}, + dhost: defaultRegistry, + expected: []docker.RegistryHost{ + testRegistryHost("https", "localhost:5000", "/2trailingslash/v2", pullCaps), + defaultRegistry, + }, + }, + { + mirrors: []string{"localhost:5000/v2/"}, + dhost: defaultRegistry, + expected: []docker.RegistryHost{ + testRegistryHost("https", "localhost:5000", "/v2", pullCaps), + defaultRegistry, + }, + }, + { + mirrors: []string{"localhost:5000/base"}, + dhost: defaultRegistry, + expected: []docker.RegistryHost{ + testRegistryHost("https", "localhost:5000", "/base/v2", pullCaps), + defaultRegistry, + }, + }, + { + // Legacy mirror configuration always appended /v2, keep functionality the same + mirrors: []string{"localhost:5000/v2/base"}, + dhost: defaultRegistry, + expected: []docker.RegistryHost{ + testRegistryHost("https", "localhost:5000", "/v2/base/v2", pullCaps), + defaultRegistry, + }, + }, + } { + actual := mirrorsToRegistryHosts(tc.mirrors, tc.dhost) + + assert.Check(t, is.DeepEqual(actual, tc.expected)) + } +} + +func testRegistryHost(scheme, host, path string, caps docker.HostCapabilities) docker.RegistryHost { + return docker.RegistryHost{ + Host: host, + Scheme: scheme, + Path: path, + Capabilities: caps, + } +} diff --git a/vendor/github.com/containerd/containerd/remotes/docker/config/config_unix.go b/vendor/github.com/containerd/containerd/remotes/docker/config/config_unix.go new file mode 100644 index 0000000000..9db18dedc6 --- /dev/null +++ b/vendor/github.com/containerd/containerd/remotes/docker/config/config_unix.go @@ -0,0 +1,42 @@ +//go:build !windows + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package config + +import ( + "crypto/x509" + "path/filepath" +) + +func hostPaths(root, host string) (hosts []string) { + ch := hostDirectory(host) + if ch != host { + hosts = append(hosts, filepath.Join(root, ch)) + } + + hosts = append(hosts, + filepath.Join(root, host), + filepath.Join(root, "_default"), + ) + + return +} + +func rootSystemPool() (*x509.CertPool, error) { + return x509.SystemCertPool() +} diff --git a/vendor/github.com/containerd/containerd/remotes/docker/config/config_windows.go b/vendor/github.com/containerd/containerd/remotes/docker/config/config_windows.go new file mode 100644 index 0000000000..4697728b9c --- /dev/null +++ b/vendor/github.com/containerd/containerd/remotes/docker/config/config_windows.go @@ -0,0 +1,41 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package config + +import ( + "crypto/x509" + "path/filepath" + "strings" +) + +func hostPaths(root, host string) (hosts []string) { + ch := hostDirectory(host) + if ch != host { + hosts = append(hosts, filepath.Join(root, strings.Replace(ch, ":", "", -1))) + } + + hosts = append(hosts, + filepath.Join(root, strings.Replace(host, ":", "", -1)), + filepath.Join(root, "_default"), + ) + + return +} + +func rootSystemPool() (*x509.CertPool, error) { + return x509.NewCertPool(), nil +} diff --git a/vendor/github.com/containerd/containerd/remotes/docker/config/docker_fuzzer_internal.go b/vendor/github.com/containerd/containerd/remotes/docker/config/docker_fuzzer_internal.go new file mode 100644 index 0000000000..335614fdb2 --- /dev/null +++ b/vendor/github.com/containerd/containerd/remotes/docker/config/docker_fuzzer_internal.go @@ -0,0 +1,44 @@ +//go:build gofuzz + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package config + +import ( + "os" + + fuzz "github.com/AdaLogics/go-fuzz-headers" +) + +func FuzzParseHostsFile(data []byte) int { + f := fuzz.NewConsumer(data) + dir, err := os.MkdirTemp("", "fuzz-") + if err != nil { + return 0 + } + err = f.CreateFiles(dir) + if err != nil { + return 0 + } + defer os.RemoveAll(dir) + b, err := f.GetBytes() + if err != nil { + return 0 + } + _, _ = parseHostsFile(dir, b) + return 1 +} diff --git a/vendor/github.com/containerd/containerd/remotes/docker/config/hosts.go b/vendor/github.com/containerd/containerd/remotes/docker/config/hosts.go new file mode 100644 index 0000000000..79a55c96a2 --- /dev/null +++ b/vendor/github.com/containerd/containerd/remotes/docker/config/hosts.go @@ -0,0 +1,612 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package config contains utilities for helping configure the Docker resolver +package config + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/containerd/containerd/remotes/docker" + "github.com/containerd/errdefs" + "github.com/containerd/log" + "github.com/pelletier/go-toml" +) + +// UpdateClientFunc is a function that lets you to amend http Client behavior used by registry clients. +type UpdateClientFunc func(client *http.Client) error + +type hostConfig struct { + scheme string + host string + path string + + capabilities docker.HostCapabilities + + caCerts []string + clientPairs [][2]string + skipVerify *bool + + header http.Header + + // TODO: Add credential configuration (domain alias, username) +} + +// HostOptions is used to configure registry hosts +type HostOptions struct { + HostDir func(string) (string, error) + Credentials func(host string) (string, string, error) + DefaultTLS *tls.Config + DefaultScheme string + // UpdateClient will be called after creating http.Client object, so clients can provide extra configuration + UpdateClient UpdateClientFunc + AuthorizerOpts []docker.AuthorizerOpt +} + +// ConfigureHosts creates a registry hosts function from the provided +// host creation options. The host directory can read hosts.toml or +// certificate files laid out in the Docker specific layout. +// If a `HostDir` function is not required, defaults are used. +func ConfigureHosts(ctx context.Context, options HostOptions) docker.RegistryHosts { + return func(host string) ([]docker.RegistryHost, error) { + var hosts []hostConfig + if options.HostDir != nil { + dir, err := options.HostDir(host) + if err != nil && !errdefs.IsNotFound(err) { + return nil, err + } + if dir != "" { + log.G(ctx).WithField("dir", dir).Debug("loading host directory") + hosts, err = loadHostDir(ctx, dir) + if err != nil { + return nil, err + } + } + } + + // If hosts was not set, add a default host + // NOTE: Check nil here and not empty, the host may be + // intentionally configured to not have any endpoints + if hosts == nil { + hosts = make([]hostConfig, 1) + } + if len(hosts) > 0 && hosts[len(hosts)-1].host == "" { + if host == "docker.io" { + hosts[len(hosts)-1].scheme = "https" + hosts[len(hosts)-1].host = "registry-1.docker.io" + } else if docker.IsLocalhost(host) { + hosts[len(hosts)-1].host = host + if options.DefaultScheme == "" { + _, port, _ := net.SplitHostPort(host) + if port == "" || port == "443" { + // If port is default or 443, only use https + hosts[len(hosts)-1].scheme = "https" + } else { + // HTTP fallback logic will be used when protocol is ambiguous + hosts[len(hosts)-1].scheme = "http" + } + + // When port is 80, protocol is not ambiguous + if port != "80" { + // Skipping TLS verification for localhost + var skipVerify = true + hosts[len(hosts)-1].skipVerify = &skipVerify + } + } else { + hosts[len(hosts)-1].scheme = options.DefaultScheme + } + } else { + hosts[len(hosts)-1].host = host + if options.DefaultScheme != "" { + hosts[len(hosts)-1].scheme = options.DefaultScheme + } else { + hosts[len(hosts)-1].scheme = "https" + } + } + hosts[len(hosts)-1].path = "/v2" + hosts[len(hosts)-1].capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush + } + + // tlsConfigured indicates that TLS was configured and HTTP endpoints should + // attempt to use the TLS configuration before falling back to HTTP + var tlsConfigured bool + + var defaultTLSConfig *tls.Config + if options.DefaultTLS != nil { + tlsConfigured = true + defaultTLSConfig = options.DefaultTLS + } else { + defaultTLSConfig = &tls.Config{} + } + + defaultTransport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + FallbackDelay: 300 * time.Millisecond, + }).DialContext, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + TLSClientConfig: defaultTLSConfig, + ExpectContinueTimeout: 5 * time.Second, + } + + client := &http.Client{ + Transport: defaultTransport, + } + if options.UpdateClient != nil { + if err := options.UpdateClient(client); err != nil { + return nil, err + } + } + + authOpts := []docker.AuthorizerOpt{docker.WithAuthClient(client)} + if options.Credentials != nil { + authOpts = append(authOpts, docker.WithAuthCreds(options.Credentials)) + } + authOpts = append(authOpts, options.AuthorizerOpts...) + authorizer := docker.NewDockerAuthorizer(authOpts...) + + rhosts := make([]docker.RegistryHost, len(hosts)) + for i, host := range hosts { + // Allow setting for each host as well + explicitTLS := tlsConfigured + + if host.caCerts != nil || host.clientPairs != nil || host.skipVerify != nil { + explicitTLS = true + tr := defaultTransport.Clone() + tlsConfig := tr.TLSClientConfig + if host.skipVerify != nil { + tlsConfig.InsecureSkipVerify = *host.skipVerify + } + if host.caCerts != nil { + if tlsConfig.RootCAs == nil { + rootPool, err := rootSystemPool() + if err != nil { + return nil, fmt.Errorf("unable to initialize cert pool: %w", err) + } + tlsConfig.RootCAs = rootPool + } + for _, f := range host.caCerts { + data, err := os.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("unable to read CA cert %q: %w", f, err) + } + if !tlsConfig.RootCAs.AppendCertsFromPEM(data) { + return nil, fmt.Errorf("unable to load CA cert %q", f) + } + } + } + + if host.clientPairs != nil { + for _, pair := range host.clientPairs { + certPEMBlock, err := os.ReadFile(pair[0]) + if err != nil { + return nil, fmt.Errorf("unable to read CERT file %q: %w", pair[0], err) + } + var keyPEMBlock []byte + if pair[1] != "" { + keyPEMBlock, err = os.ReadFile(pair[1]) + if err != nil { + return nil, fmt.Errorf("unable to read CERT file %q: %w", pair[1], err) + } + } else { + // Load key block from same PEM file + keyPEMBlock = certPEMBlock + } + cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock) + if err != nil { + return nil, fmt.Errorf("failed to load X509 key pair: %w", err) + } + + tlsConfig.Certificates = append(tlsConfig.Certificates, cert) + } + } + + c := *client + c.Transport = tr + if options.UpdateClient != nil { + if err := options.UpdateClient(&c); err != nil { + return nil, err + } + } + + rhosts[i].Client = &c + rhosts[i].Authorizer = docker.NewDockerAuthorizer(append(authOpts, docker.WithAuthClient(&c))...) + } else { + rhosts[i].Client = client + rhosts[i].Authorizer = authorizer + } + + // When TLS has been configured for the operation or host and + // the protocol from the port number is ambiguous, use the + // docker.NewHTTPFallback roundtripper to catch TLS errors and re-attempt the + // request as http. This allows preference for https when configured but + // also catches TLS errors early enough in the request to avoid sending + // the request twice or consuming the request body. + if host.scheme == "http" && explicitTLS { + _, port, _ := net.SplitHostPort(host.host) + if port != "" && port != "80" { + 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") + host.scheme = "https" + rhosts[i].Client.Transport = docker.NewHTTPFallback(rhosts[i].Client.Transport) + } + } + + rhosts[i].Scheme = host.scheme + rhosts[i].Host = host.host + rhosts[i].Path = host.path + rhosts[i].Capabilities = host.capabilities + rhosts[i].Header = host.header + } + + return rhosts, nil + } + +} + +// HostDirFromRoot returns a function which finds a host directory +// based at the given root. +func HostDirFromRoot(root string) func(string) (string, error) { + return func(host string) (string, error) { + for _, p := range hostPaths(root, host) { + if _, err := os.Stat(p); err == nil { + return p, nil + } else if !os.IsNotExist(err) { + return "", err + } + } + return "", errdefs.ErrNotFound + } +} + +// hostDirectory converts ":port" to "_port_" in directory names +func hostDirectory(host string) string { + idx := strings.LastIndex(host, ":") + if idx > 0 { + return host[:idx] + "_" + host[idx+1:] + "_" + } + return host +} + +func loadHostDir(ctx context.Context, hostsDir string) ([]hostConfig, error) { + b, err := os.ReadFile(filepath.Join(hostsDir, "hosts.toml")) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + if len(b) == 0 { + // If hosts.toml does not exist, fallback to checking for + // certificate files based on Docker's certificate file + // pattern (".crt", ".cert", ".key" files) + return loadCertFiles(ctx, hostsDir) + } + + hosts, err := parseHostsFile(hostsDir, b) + if err != nil { + log.G(ctx).WithError(err).Error("failed to decode hosts.toml") + // Fallback to checking certificate files + return loadCertFiles(ctx, hostsDir) + } + + return hosts, nil +} + +type hostFileConfig struct { + // Capabilities determine what operations a host is + // capable of performing. Allowed values + // - pull + // - resolve + // - push + Capabilities []string `toml:"capabilities"` + + // CACert are the public key certificates for TLS + // Accepted types + // - string - Single file with certificate(s) + // - []string - Multiple files with certificates + CACert interface{} `toml:"ca"` + + // Client keypair(s) for TLS with client authentication + // Accepted types + // - string - Single file with public and private keys + // - []string - Multiple files with public and private keys + // - [][2]string - Multiple keypairs with public and private keys in separate files + Client interface{} `toml:"client"` + + // SkipVerify skips verification of the server's certificate chain + // and host name. This should only be used for testing or in + // combination with other methods of verifying connections. + SkipVerify *bool `toml:"skip_verify"` + + // Header are additional header files to send to the server + Header map[string]interface{} `toml:"header"` + + // OverridePath indicates the API root endpoint is defined in the URL + // path rather than by the API specification. + // This may be used with non-compliant OCI registries to override the + // API root endpoint. + OverridePath bool `toml:"override_path"` + + // TODO: Credentials: helper? name? username? alternate domain? token? +} + +func parseHostsFile(baseDir string, b []byte) ([]hostConfig, error) { + tree, err := toml.LoadBytes(b) + if err != nil { + return nil, fmt.Errorf("failed to parse TOML: %w", err) + } + + // HACK: we want to keep toml parsing structures private in this package, however go-toml ignores private embedded types. + // so we remap it to a public type within the func body, so technically it's public, but not possible to import elsewhere. + type HostFileConfig = hostFileConfig + + c := struct { + HostFileConfig + // Server specifies the default server. When `host` is + // also specified, those hosts are tried first. + Server string `toml:"server"` + // HostConfigs store the per-host configuration + HostConfigs map[string]hostFileConfig `toml:"host"` + }{} + + orderedHosts, err := getSortedHosts(tree) + if err != nil { + return nil, err + } + + var ( + hosts []hostConfig + ) + + if err := tree.Unmarshal(&c); err != nil { + return nil, err + } + + // Parse hosts array + for _, host := range orderedHosts { + config := c.HostConfigs[host] + + parsed, err := parseHostConfig(host, baseDir, config) + if err != nil { + return nil, err + } + hosts = append(hosts, parsed) + } + + // Parse root host config and append it as the last element + parsed, err := parseHostConfig(c.Server, baseDir, c.HostFileConfig) + if err != nil { + return nil, err + } + hosts = append(hosts, parsed) + + return hosts, nil +} + +func parseHostConfig(server string, baseDir string, config hostFileConfig) (hostConfig, error) { + var ( + result = hostConfig{} + err error + ) + + if server != "" { + if !strings.HasPrefix(server, "http") { + server = "https://" + server + } + u, err := url.Parse(server) + if err != nil { + return hostConfig{}, fmt.Errorf("unable to parse server %v: %w", server, err) + } + result.scheme = u.Scheme + result.host = u.Host + if len(u.Path) > 0 { + u.Path = path.Clean(u.Path) + if !strings.HasSuffix(u.Path, "/v2") && !config.OverridePath { + u.Path = u.Path + "/v2" + } + } else if !config.OverridePath { + u.Path = "/v2" + } + result.path = u.Path + } + + result.skipVerify = config.SkipVerify + + if len(config.Capabilities) > 0 { + for _, c := range config.Capabilities { + switch strings.ToLower(c) { + case "pull": + result.capabilities |= docker.HostCapabilityPull + case "resolve": + result.capabilities |= docker.HostCapabilityResolve + case "push": + result.capabilities |= docker.HostCapabilityPush + default: + return hostConfig{}, fmt.Errorf("unknown capability %v", c) + } + } + } else { + result.capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush + } + + if config.CACert != nil { + switch cert := config.CACert.(type) { + case string: + result.caCerts = []string{makeAbsPath(cert, baseDir)} + case []interface{}: + result.caCerts, err = makeStringSlice(cert, func(p string) string { + return makeAbsPath(p, baseDir) + }) + if err != nil { + return hostConfig{}, err + } + default: + return hostConfig{}, fmt.Errorf("invalid type %v for \"ca\"", cert) + } + } + + if config.Client != nil { + switch client := config.Client.(type) { + case string: + result.clientPairs = [][2]string{{makeAbsPath(client, baseDir), ""}} + case []interface{}: + // []string or [][2]string + for _, pairs := range client { + switch p := pairs.(type) { + case string: + result.clientPairs = append(result.clientPairs, [2]string{makeAbsPath(p, baseDir), ""}) + case []interface{}: + slice, err := makeStringSlice(p, func(s string) string { + return makeAbsPath(s, baseDir) + }) + if err != nil { + return hostConfig{}, err + } + if len(slice) != 2 { + return hostConfig{}, fmt.Errorf("invalid pair %v for \"client\"", p) + } + + var pair [2]string + copy(pair[:], slice) + result.clientPairs = append(result.clientPairs, pair) + default: + return hostConfig{}, fmt.Errorf("invalid type %T for \"client\"", p) + } + } + default: + return hostConfig{}, fmt.Errorf("invalid type %v for \"client\"", client) + } + } + + if config.Header != nil { + header := http.Header{} + for key, ty := range config.Header { + switch value := ty.(type) { + case string: + header[key] = []string{value} + case []interface{}: + header[key], err = makeStringSlice(value, nil) + if err != nil { + return hostConfig{}, err + } + default: + return hostConfig{}, fmt.Errorf("invalid type %v for header %q", ty, key) + } + } + result.header = header + } + + return result, nil +} + +// getSortedHosts returns the list of hosts as they defined in the file. +func getSortedHosts(root *toml.Tree) ([]string, error) { + iter, ok := root.Get("host").(*toml.Tree) + if !ok { + return nil, errors.New("invalid `host` tree") + } + + list := append([]string{}, iter.Keys()...) + + // go-toml stores TOML sections in the map object, so no order guaranteed. + // We retrieve line number for each key and sort the keys by position. + sort.Slice(list, func(i, j int) bool { + h1 := iter.GetPath([]string{list[i]}).(*toml.Tree) + h2 := iter.GetPath([]string{list[j]}).(*toml.Tree) + return h1.Position().Line < h2.Position().Line + }) + + return list, nil +} + +// makeStringSlice is a helper func to convert from []interface{} to []string. +// Additionally an optional cb func may be passed to perform string mapping. +func makeStringSlice(slice []interface{}, cb func(string) string) ([]string, error) { + out := make([]string, len(slice)) + for i, value := range slice { + str, ok := value.(string) + if !ok { + return nil, fmt.Errorf("unable to cast %v to string", value) + } + + if cb != nil { + out[i] = cb(str) + } else { + out[i] = str + } + } + return out, nil +} + +func makeAbsPath(p string, base string) string { + if filepath.IsAbs(p) { + return p + } + return filepath.Join(base, p) +} + +// loadCertsDir loads certs from certsDir like "/etc/docker/certs.d" . +// Compatible with Docker file layout +// - files ending with ".crt" are treated as CA certificate files +// - files ending with ".cert" are treated as client certificates, and +// files with the same name but ending with ".key" are treated as the +// corresponding private key. +// NOTE: If a ".key" file is missing, this function will just return +// the ".cert", which may contain the private key. If the ".cert" file +// does not contain the private key, the caller should detect and error. +func loadCertFiles(ctx context.Context, certsDir string) ([]hostConfig, error) { + fs, err := os.ReadDir(certsDir) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + hosts := make([]hostConfig, 1) + for _, f := range fs { + if f.IsDir() { + continue + } + if strings.HasSuffix(f.Name(), ".crt") { + hosts[0].caCerts = append(hosts[0].caCerts, filepath.Join(certsDir, f.Name())) + } + if strings.HasSuffix(f.Name(), ".cert") { + var pair [2]string + certFile := f.Name() + pair[0] = filepath.Join(certsDir, certFile) + // Check if key also exists + keyFile := filepath.Join(certsDir, certFile[:len(certFile)-5]+".key") + if _, err := os.Stat(keyFile); err == nil { + pair[1] = keyFile + } else if !os.IsNotExist(err) { + return nil, err + } + hosts[0].clientPairs = append(hosts[0].clientPairs, pair) + } + } + return hosts, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 2700c2da82..53a26d18de 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -313,6 +313,7 @@ github.com/containerd/containerd/reference github.com/containerd/containerd/remotes github.com/containerd/containerd/remotes/docker github.com/containerd/containerd/remotes/docker/auth +github.com/containerd/containerd/remotes/docker/config github.com/containerd/containerd/remotes/docker/schema1 github.com/containerd/containerd/remotes/errors github.com/containerd/containerd/rootfs