diff --git a/cli/command/registry.go b/cli/command/registry.go index 2e767d5b72..4893a9fa71 100644 --- a/cli/command/registry.go +++ b/cli/command/registry.go @@ -15,6 +15,7 @@ import ( "github.com/docker/cli/cli/hints" "github.com/docker/cli/cli/streams" "github.com/docker/cli/internal/prompt" + "github.com/docker/cli/internal/secretsengine" "github.com/docker/cli/internal/tui" "github.com/moby/moby/api/pkg/authconfig" registrytypes "github.com/moby/moby/api/types/registry" @@ -35,6 +36,16 @@ const ( // [registry.IndexServer]: https://pkg.go.dev/github.com/docker/docker@v28.3.3+incompatible/registry#IndexServer const authConfigKey = "https://index.docker.io/v1/" +var desktopAuthConfig = secretsengine.AuthConfig + +func envAuthConfig(registryHostname string) (configtypes.AuthConfig, bool) { + if os.Getenv(configfile.DockerEnvConfigKey) == "" { + return configtypes.AuthConfig{}, false + } + authConfig, err := configfile.New("").GetAuthConfig(registryHostname) + return authConfig, err == nil && authConfig.Username != "" +} + // ResolveAuthConfig returns auth-config for the given registry from the // credential-store. It returns an empty AuthConfig if no credentials were // found. @@ -199,9 +210,16 @@ func RetrieveAuthTokenFromImage(cfg *configfile.ConfigFile, image string) (strin if err != nil { return "", err } - authConfig, err := cfg.GetAuthConfig(reference.Domain(registryRef)) - if err != nil { - return "", err + domain := reference.Domain(registryRef) + authConfig, ok := envAuthConfig(domain) + if !ok { + authConfig, ok = desktopAuthConfig(domain) + } + if !ok { + authConfig, err = cfg.GetAuthConfig(domain) + if err != nil { + return "", err + } } return authconfig.Encode(registrytypes.AuthConfig{ diff --git a/docs/reference/commandline/login.md b/docs/reference/commandline/login.md index 458edd7b91..660e925835 100644 --- a/docs/reference/commandline/login.md +++ b/docs/reference/commandline/login.md @@ -70,6 +70,18 @@ With Docker Desktop, the credential store is already installed and configured for you. Unless you want to change the credential store used by Docker Desktop, you can skip the following steps. +#### Docker Desktop sign-in + +When Docker Desktop is running and you are signed in, commands that pull or +push images through the Docker Engine, such as `docker pull`, `docker push`, +`docker run`, and `docker service create`, use the Docker Desktop session for +Docker Hub (`docker.io`) and Docker Hardened Images (`dhi.io`). The session is +used before the credential store, credential helpers, and the configuration +file. Credentials set in the `DOCKER_AUTH_CONFIG` environment variable take +precedence over the Docker Desktop session. If Docker Desktop is not installed +or not running, or you are not signed in, the Docker CLI uses its own +credentials. + #### Configure the credential store You need to specify the credential store in `$HOME/.docker/config.json` diff --git a/internal/secretsengine/secretsengine.go b/internal/secretsengine/secretsengine.go new file mode 100644 index 0000000000..6840c5d53c --- /dev/null +++ b/internal/secretsengine/secretsengine.go @@ -0,0 +1,215 @@ +//go:build go1.26 + +package secretsengine + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/docker/cli/cli/config/credentials" + "github.com/docker/cli/cli/config/types" + seclient "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/client/dockerhub" + "github.com/docker/secrets-engine/x/api" + "github.com/sirupsen/logrus" +) + +const ( + lookupTimeout = 5 * time.Second + sessionExpirySkew = 30 * time.Second + sessionTTL = time.Minute + retryInterval = time.Minute + + indexServer = "https://index.docker.io/v1/" + dhiRegistryHost = "dhi.io" + stagingRegistryHost = "registry-1-stage.docker.io" +) + +var ( + errNoUsername = errors.New("no username for the session") + errSessionExpired = errors.New("the session has expired (not signed in to Docker Desktop?)") +) + +type hubSessions interface { + GetDefaultSession(ctx context.Context) (dockerhub.UserSession, error) + GetDefaultProfile(ctx context.Context) (dockerhub.Profile, error) +} + +var _ hubSessions = dockerhub.ClientAuth(nil) + +type provider struct { + hub *session + staging *session +} + +var shared = &provider{ + hub: newSession("Docker Hub"), + staging: newSession("Docker Hub staging", dockerhub.Staging()), +} + +var disabled atomic.Bool + +func Disable() { + disabled.Store(true) +} + +func AuthConfig(registryHostname string) (types.AuthConfig, bool) { + if disabled.Load() { + return types.AuthConfig{}, false + } + return shared.authConfig(registryHostname) +} + +func (p *provider) authConfig(registryHostname string) (types.AuthConfig, bool) { + s, serverAddress := p.sessionFor(registryHostname) + if s == nil { + return types.AuthConfig{}, false + } + auth, ok := s.auth() + if !ok { + return types.AuthConfig{}, false + } + auth.ServerAddress = serverAddress + return auth, true +} + +func (p *provider) sessionFor(registryHostname string) (*session, string) { + switch credentials.ConvertToHostname(registryHostname) { + case "docker.io", "index.docker.io", "registry-1.docker.io": + return p.hub, indexServer + case dhiRegistryHost: + return p.hub, dhiRegistryHost + case stagingRegistryHost: + return p.staging, stagingRegistryHost + default: + return nil, "" + } +} + +type session struct { + name string + connect func() (hubSessions, error) + timeout time.Duration + now func() time.Time + + mu sync.Mutex + hub hubSessions + retryAt time.Time + cached *types.AuthConfig + validUntil time.Time +} + +func newSession(name string, opts ...dockerhub.Option) *session { + return &session{ + name: name, + connect: func() (hubSessions, error) { + return connect(api.DesktopSocketPath(), opts...) + }, + timeout: lookupTimeout, + now: time.Now, + } +} + +func connect(socketPath string, opts ...dockerhub.Option) (hubSessions, error) { + if _, err := os.Lstat(socketPath); errors.Is(err, fs.ErrNotExist) { + return nil, err + } + c, err := seclient.New( + seclient.WithSocketPath(socketPath), + seclient.WithTimeout(lookupTimeout), + ) + if err != nil { + return nil, err + } + return c.HubAuth(opts...), nil +} + +func (s *session) auth() (types.AuthConfig, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + now := s.now() + if now.Before(s.retryAt) { + return types.AuthConfig{}, false + } + if s.cached != nil && now.Before(s.validUntil) { + return *s.cached, true + } + auth, validUntil, err := s.fetch() + if err != nil { + s.retryAt = s.now().Add(retryInterval) + s.cached = nil + logFallback(s.name, err) + return types.AuthConfig{}, false + } + logrus.Debugf("using the %s session of %q from Docker Desktop", s.name, auth.Username) + s.cached, s.validUntil = &auth, validUntil + return auth, true +} + +func (s *session) fetch() (types.AuthConfig, time.Time, error) { + if s.hub == nil { + hub, err := s.connect() + if err != nil { + return types.AuthConfig{}, time.Time{}, err + } + s.hub = hub + } + + ctx, cancel := context.WithTimeout(context.Background(), s.timeout) + defer cancel() + + userSession, err := s.hub.GetDefaultSession(ctx) + if err != nil { + return types.AuthConfig{}, time.Time{}, err + } + + username := userSession.Claims.Username + if username == "" { + profile, err := s.hub.GetDefaultProfile(ctx) + if err != nil { + return types.AuthConfig{}, time.Time{}, fmt.Errorf("resolving the Docker Hub username: %w", err) + } + username = profile.Username + } + if username == "" { + return types.AuthConfig{}, time.Time{}, errNoUsername + } + + now := s.now() + validUntil := now.Add(sessionTTL) + if exp := userSession.Claims.ExpiresAt; exp != nil { + validUntil = exp.Add(-sessionExpirySkew) + if !now.Before(validUntil) { + return types.AuthConfig{}, time.Time{}, errSessionExpired + } + } + + return types.AuthConfig{ + Username: username, + Password: userSession.AccessToken, + }, validUntil, nil +} + +func logFallback(name string, err error) { + if isSessionUnavailable(err) { + logrus.Debugf("no %s session available from Docker Desktop, using Docker CLI credentials: %v", name, err) + return + } + logrus.Warnf("could not use the %s session from Docker Desktop, using Docker CLI credentials instead: %v", name, err) +} + +func isSessionUnavailable(err error) bool { + if _, ok := errors.AsType[*seclient.ConnectError](err); ok { + return true + } + return errors.Is(err, fs.ErrNotExist) || + errors.Is(err, dockerhub.ErrNoSession) || + errors.Is(err, errSessionExpired) +}