From c1a53ae7b665ed7072c6bb2813c3df47d91d9493 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 5 Nov 2025 13:17:59 +0100 Subject: [PATCH] cmd/docker-trust: remove dependency on cli/internal Create a copy of the registry package to use, so that code used only for trust can be removed from the cli/internal package. Signed-off-by: Sebastiaan van Stijn --- cmd/docker-trust/internal/registry/auth.go | 4 + cmd/docker-trust/internal/registry/config.go | 79 +++++ cmd/docker-trust/internal/registry/doc.go | 12 + cmd/docker-trust/internal/registry/errors.go | 24 ++ .../internal/registry/registry.go | 101 +++++++ .../internal/registry/registry_test.go | 281 ++++++++++++++++++ cmd/docker-trust/internal/trust/trust.go | 2 +- internal/registry/registry.go | 7 - 8 files changed, 502 insertions(+), 8 deletions(-) create mode 100644 cmd/docker-trust/internal/registry/auth.go create mode 100644 cmd/docker-trust/internal/registry/config.go create mode 100644 cmd/docker-trust/internal/registry/doc.go create mode 100644 cmd/docker-trust/internal/registry/errors.go create mode 100644 cmd/docker-trust/internal/registry/registry.go create mode 100644 cmd/docker-trust/internal/registry/registry_test.go diff --git a/cmd/docker-trust/internal/registry/auth.go b/cmd/docker-trust/internal/registry/auth.go new file mode 100644 index 0000000000..98e1b42edd --- /dev/null +++ b/cmd/docker-trust/internal/registry/auth.go @@ -0,0 +1,4 @@ +package registry + +// AuthClientID is used the ClientID used for the token server +const AuthClientID = "docker" diff --git a/cmd/docker-trust/internal/registry/config.go b/cmd/docker-trust/internal/registry/config.go new file mode 100644 index 0000000000..07237020e6 --- /dev/null +++ b/cmd/docker-trust/internal/registry/config.go @@ -0,0 +1,79 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.24 + +package registry + +import ( + "net" + "strings" + + "github.com/distribution/reference" + "github.com/moby/moby/api/types/registry" +) + +// IndexName is the name of the index +const IndexName = "docker.io" + +func normalizeIndexName(val string) string { + if val == "index.docker.io" { + return "docker.io" + } + return val +} + +// NewIndexInfo creates a new [registry.IndexInfo] or the given +// repository-name, and detects whether the registry is considered +// "secure" (non-localhost). +func NewIndexInfo(reposName reference.Named) *registry.IndexInfo { + indexName := normalizeIndexName(reference.Domain(reposName)) + if indexName == IndexName { + return ®istry.IndexInfo{ + Name: IndexName, + Secure: true, + Official: true, + } + } + + return ®istry.IndexInfo{ + Name: indexName, + Secure: !isInsecure(indexName), + } +} + +// isInsecure is used to detect whether a registry domain or IP-address is allowed +// to use an insecure (non-TLS, or self-signed cert) connection according to the +// defaults, which allows for insecure connections with registries running on a +// loopback address ("localhost", "::1/128", "127.0.0.0/8"). +// +// It is used in situations where we don't have access to the daemon's configuration, +// for example, when used from the client / CLI. +func isInsecure(hostNameOrIP string) bool { + // Attempt to strip port if present; this also strips brackets for + // IPv6 addresses with a port (e.g. "[::1]:5000"). + // + // This is best-effort; we'll continue using the address as-is if it fails. + if host, _, err := net.SplitHostPort(hostNameOrIP); err == nil { + hostNameOrIP = host + } + if hostNameOrIP == "127.0.0.1" || hostNameOrIP == "::1" || strings.EqualFold(hostNameOrIP, "localhost") { + // Fast path; no need to resolve these, assuming nobody overrides + // "localhost" for anything else than a loopback address (sorry, not sorry). + return true + } + + var addresses []net.IP + if ip := net.ParseIP(hostNameOrIP); ip != nil { + addresses = append(addresses, ip) + } else { + // Try to resolve the host's IP-addresses. + addrs, _ := net.LookupIP(hostNameOrIP) + addresses = append(addresses, addrs...) + } + + for _, addr := range addresses { + if addr.IsLoopback() { + return true + } + } + return false +} diff --git a/cmd/docker-trust/internal/registry/doc.go b/cmd/docker-trust/internal/registry/doc.go new file mode 100644 index 0000000000..0b6a24767c --- /dev/null +++ b/cmd/docker-trust/internal/registry/doc.go @@ -0,0 +1,12 @@ +// Package registry is a fork of [github.com/docker/docker/registry], taken +// at commit [moby@49306c6]. Git history was not preserved in this fork, +// but can be found using the URLs provided. +// +// This fork was created to remove the dependency on the "Moby" codebase, +// and because the CLI only needs a subset of its features. The original +// package was written specifically for use in the daemon code, and includes +// functionality that cannot be used in the CLI. +// +// [github.com/docker/docker/registry]: https://pkg.go.dev/github.com/docker/docker@v28.3.2+incompatible/registry +// [moby@49306c6]: https://github.com/moby/moby/tree/49306c607b72c5bf0a8e426f5a9760fa5ef96ea0/registry +package registry diff --git a/cmd/docker-trust/internal/registry/errors.go b/cmd/docker-trust/internal/registry/errors.go new file mode 100644 index 0000000000..06533446a9 --- /dev/null +++ b/cmd/docker-trust/internal/registry/errors.go @@ -0,0 +1,24 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.24 + +package registry + +import ( + "fmt" +) + +func invalidParam(err error) error { + return invalidParameterErr{err} +} + +func invalidParamf(format string, args ...any) error { + return invalidParameterErr{fmt.Errorf(format, args...)} +} + +type invalidParameterErr struct{ error } + +func (invalidParameterErr) InvalidParameter() {} + +func (e invalidParameterErr) Unwrap() error { + return e.error +} diff --git a/cmd/docker-trust/internal/registry/registry.go b/cmd/docker-trust/internal/registry/registry.go new file mode 100644 index 0000000000..dce11dcc06 --- /dev/null +++ b/cmd/docker-trust/internal/registry/registry.go @@ -0,0 +1,101 @@ +// Package registry contains client primitives to interact with a remote Docker registry. +package registry + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/docker/distribution/registry/client/transport" + "github.com/docker/go-connections/tlsconfig" + "github.com/sirupsen/logrus" +) + +func hasFile(files []os.DirEntry, name string) bool { + for _, f := range files { + if f.Name() == name { + return true + } + } + return false +} + +// ReadCertsDirectory reads the directory for TLS certificates +// including roots and certificate pairs and updates the +// provided TLS configuration. +func ReadCertsDirectory(tlsConfig *tls.Config, directory string) error { + return loadTLSConfig(context.TODO(), directory, tlsConfig) +} + +// loadTLSConfig reads the directory for TLS certificates including roots and +// certificate pairs, and updates the provided TLS configuration. +func loadTLSConfig(ctx context.Context, directory string, tlsConfig *tls.Config) error { + fs, err := os.ReadDir(directory) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return invalidParam(err) + } + + for _, f := range fs { + if ctx.Err() != nil { + return ctx.Err() + } + switch filepath.Ext(f.Name()) { + case ".crt": + if tlsConfig.RootCAs == nil { + systemPool, err := tlsconfig.SystemCertPool() + if err != nil { + return invalidParam(fmt.Errorf("unable to get system cert pool: %w", err)) + } + tlsConfig.RootCAs = systemPool + } + fileName := filepath.Join(directory, f.Name()) + logrus.Debugf("crt: %s", fileName) + data, err := os.ReadFile(fileName) + if err != nil { + return err + } + tlsConfig.RootCAs.AppendCertsFromPEM(data) + case ".cert": + certName := f.Name() + keyName := certName[:len(certName)-5] + ".key" + logrus.Debugf("cert: %s", filepath.Join(directory, certName)) + if !hasFile(fs, keyName) { + return invalidParamf("missing key %s for client certificate %s. CA certificates must use the extension .crt", keyName, certName) + } + cert, err := tls.LoadX509KeyPair(filepath.Join(directory, certName), filepath.Join(directory, keyName)) + if err != nil { + return err + } + tlsConfig.Certificates = append(tlsConfig.Certificates, cert) + case ".key": + keyName := f.Name() + certName := keyName[:len(keyName)-4] + ".cert" + logrus.Debugf("key: %s", filepath.Join(directory, keyName)) + if !hasFile(fs, certName) { + return invalidParamf("missing client certificate %s for key %s", certName, keyName) + } + } + } + + return nil +} + +// Headers returns request modifiers with a User-Agent and metaHeaders +func Headers(userAgent string, metaHeaders http.Header) []transport.RequestModifier { + modifiers := []transport.RequestModifier{} + if userAgent != "" { + modifiers = append(modifiers, transport.NewHeaderRequestModifier(http.Header{ + "User-Agent": []string{userAgent}, + })) + } + if metaHeaders != nil { + modifiers = append(modifiers, transport.NewHeaderRequestModifier(metaHeaders)) + } + return modifiers +} diff --git a/cmd/docker-trust/internal/registry/registry_test.go b/cmd/docker-trust/internal/registry/registry_test.go new file mode 100644 index 0000000000..676076fc93 --- /dev/null +++ b/cmd/docker-trust/internal/registry/registry_test.go @@ -0,0 +1,281 @@ +package registry + +import ( + "testing" + + "github.com/distribution/reference" + "github.com/moby/moby/api/types/registry" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestNewIndexInfo(t *testing.T) { + type staticRepositoryInfo struct { + Index *registry.IndexInfo + RemoteName string + CanonicalName string + LocalName string + } + + tests := map[string]staticRepositoryInfo{ + "fooo/bar": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "fooo/bar", + LocalName: "fooo/bar", + CanonicalName: "docker.io/fooo/bar", + }, + "library/ubuntu": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu", + LocalName: "ubuntu", + CanonicalName: "docker.io/library/ubuntu", + }, + "nonlibrary/ubuntu": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "nonlibrary/ubuntu", + LocalName: "nonlibrary/ubuntu", + CanonicalName: "docker.io/nonlibrary/ubuntu", + }, + "ubuntu": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu", + LocalName: "ubuntu", + CanonicalName: "docker.io/library/ubuntu", + }, + "other/library": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "other/library", + LocalName: "other/library", + CanonicalName: "docker.io/other/library", + }, + "127.0.0.1:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "127.0.0.1:8000", + Official: false, + Secure: false, + }, + RemoteName: "private/moonbase", + LocalName: "127.0.0.1:8000/private/moonbase", + CanonicalName: "127.0.0.1:8000/private/moonbase", + }, + "127.0.0.1:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "127.0.0.1:8000", + Official: false, + Secure: false, + }, + RemoteName: "privatebase", + LocalName: "127.0.0.1:8000/privatebase", + CanonicalName: "127.0.0.1:8000/privatebase", + }, + "[::1]:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "[::1]:8000", + Official: false, + Secure: false, + }, + RemoteName: "private/moonbase", + LocalName: "[::1]:8000/private/moonbase", + CanonicalName: "[::1]:8000/private/moonbase", + }, + "[::1]:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "[::1]:8000", + Official: false, + Secure: false, + }, + RemoteName: "privatebase", + LocalName: "[::1]:8000/privatebase", + CanonicalName: "[::1]:8000/privatebase", + }, + // IPv6 only has a single loopback address, so ::2 is not a loopback, + // hence not marked "insecure". + "[::2]:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "[::2]:8000", + Official: false, + Secure: true, + }, + RemoteName: "private/moonbase", + LocalName: "[::2]:8000/private/moonbase", + CanonicalName: "[::2]:8000/private/moonbase", + }, + // IPv6 only has a single loopback address, so ::2 is not a loopback, + // hence not marked "insecure". + "[::2]:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "[::2]:8000", + Official: false, + Secure: true, + }, + RemoteName: "privatebase", + LocalName: "[::2]:8000/privatebase", + CanonicalName: "[::2]:8000/privatebase", + }, + "localhost:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "localhost:8000", + Official: false, + Secure: false, + }, + RemoteName: "private/moonbase", + LocalName: "localhost:8000/private/moonbase", + CanonicalName: "localhost:8000/private/moonbase", + }, + "localhost:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "localhost:8000", + Official: false, + Secure: false, + }, + RemoteName: "privatebase", + LocalName: "localhost:8000/privatebase", + CanonicalName: "localhost:8000/privatebase", + }, + "example.com/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "example.com", + Official: false, + Secure: true, + }, + RemoteName: "private/moonbase", + LocalName: "example.com/private/moonbase", + CanonicalName: "example.com/private/moonbase", + }, + "example.com/privatebase": { + Index: ®istry.IndexInfo{ + Name: "example.com", + Official: false, + Secure: true, + }, + RemoteName: "privatebase", + LocalName: "example.com/privatebase", + CanonicalName: "example.com/privatebase", + }, + "example.com:8000/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "example.com:8000", + Official: false, + Secure: true, + }, + RemoteName: "private/moonbase", + LocalName: "example.com:8000/private/moonbase", + CanonicalName: "example.com:8000/private/moonbase", + }, + "example.com:8000/privatebase": { + Index: ®istry.IndexInfo{ + Name: "example.com:8000", + Official: false, + Secure: true, + }, + RemoteName: "privatebase", + LocalName: "example.com:8000/privatebase", + CanonicalName: "example.com:8000/privatebase", + }, + "localhost/private/moonbase": { + Index: ®istry.IndexInfo{ + Name: "localhost", + Official: false, + Secure: false, + }, + RemoteName: "private/moonbase", + LocalName: "localhost/private/moonbase", + CanonicalName: "localhost/private/moonbase", + }, + "localhost/privatebase": { + Index: ®istry.IndexInfo{ + Name: "localhost", + Official: false, + Secure: false, + }, + RemoteName: "privatebase", + LocalName: "localhost/privatebase", + CanonicalName: "localhost/privatebase", + }, + IndexName + "/public/moonbase": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "public/moonbase", + LocalName: "public/moonbase", + CanonicalName: "docker.io/public/moonbase", + }, + "index." + IndexName + "/public/moonbase": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "public/moonbase", + LocalName: "public/moonbase", + CanonicalName: "docker.io/public/moonbase", + }, + "ubuntu-12.04-base": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu-12.04-base", + LocalName: "ubuntu-12.04-base", + CanonicalName: "docker.io/library/ubuntu-12.04-base", + }, + IndexName + "/ubuntu-12.04-base": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu-12.04-base", + LocalName: "ubuntu-12.04-base", + CanonicalName: "docker.io/library/ubuntu-12.04-base", + }, + "index." + IndexName + "/ubuntu-12.04-base": { + Index: ®istry.IndexInfo{ + Name: IndexName, + Official: true, + Secure: true, + }, + RemoteName: "library/ubuntu-12.04-base", + LocalName: "ubuntu-12.04-base", + CanonicalName: "docker.io/library/ubuntu-12.04-base", + }, + } + + for reposName, expected := range tests { + t.Run(reposName, func(t *testing.T) { + named, err := reference.ParseNormalizedNamed(reposName) + assert.NilError(t, err) + + indexInfo := NewIndexInfo(named) + repoInfoName := reference.TrimNamed(named) + + assert.Check(t, is.DeepEqual(indexInfo, expected.Index)) + assert.Check(t, is.Equal(reference.Path(repoInfoName), expected.RemoteName)) + assert.Check(t, is.Equal(reference.FamiliarName(repoInfoName), expected.LocalName)) + assert.Check(t, is.Equal(repoInfoName.Name(), expected.CanonicalName)) + }) + } +} diff --git a/cmd/docker-trust/internal/trust/trust.go b/cmd/docker-trust/internal/trust/trust.go index d7b274a0c0..a20cb273af 100644 --- a/cmd/docker-trust/internal/trust/trust.go +++ b/cmd/docker-trust/internal/trust/trust.go @@ -17,7 +17,7 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/config" - "github.com/docker/cli/internal/registry" + "github.com/docker/cli/cmd/docker-trust/internal/registry" "github.com/docker/distribution/registry/client/auth" "github.com/docker/distribution/registry/client/auth/challenge" "github.com/docker/distribution/registry/client/transport" diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 97946d7fd8..7832529ee6 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -56,13 +56,6 @@ func hasFile(files []os.DirEntry, name string) bool { return false } -// ReadCertsDirectory reads the directory for TLS certificates -// including roots and certificate pairs and updates the -// provided TLS configuration. -func ReadCertsDirectory(tlsConfig *tls.Config, directory string) error { - return loadTLSConfig(context.TODO(), directory, tlsConfig) -} - // loadTLSConfig reads the directory for TLS certificates including roots and // certificate pairs, and updates the provided TLS configuration. func loadTLSConfig(ctx context.Context, directory string, tlsConfig *tls.Config) error {