diff --git a/docs/containers-registries.conf.5.md b/docs/containers-registries.conf.5.md index f0a999567..a078a1474 100644 --- a/docs/containers-registries.conf.5.md +++ b/docs/containers-registries.conf.5.md @@ -16,6 +16,8 @@ Container engines will use the `$HOME/.config/containers/registries.conf` if it `unqualified-search-registries` : An array of _host_[`:`_port_] registries to try when pulling an unqualified image, in order. +`credential-helpers`: An array of credential helpers used as external credential store for the registries. + ### NAMESPACED `[[registry]]` SETTINGS The bulk of the configuration is represented as an array of `[[registry]]` @@ -51,6 +53,9 @@ Given an image name, a single `[[registry]]` TOML table is chosen based on its ` : `true` or `false`. If `true`, pulling images with matching names is forbidden. +`credential-helper` +: The credential helper for registry specified by the `prefix` field. Global default `credential-helpers` is used if `credential-helper` is not specified. This configuration only works if the `prefix` field is a registry domain, not used for inner namespace. + #### Remapping and mirroring registries The user-specified image reference is, primarily, a "logical" image name, always used for naming diff --git a/pkg/docker/config/config.go b/pkg/docker/config/config.go index 983df41d8..7db3ad681 100644 --- a/pkg/docker/config/config.go +++ b/pkg/docker/config/config.go @@ -10,6 +10,7 @@ import ( "runtime" "strings" + "github.com/containers/image/v5/pkg/sysregistriesv2" "github.com/containers/image/v5/types" "github.com/containers/storage/pkg/homedir" helperclient "github.com/docker/docker-credential-helpers/client" @@ -54,9 +55,23 @@ var ( ErrNotSupported = errors.New("not supported") ) -// SetAuthentication stores the username and password in the auth.json file +// SetAuthentication stores the username and password in the credential helper or file func SetAuthentication(sys *types.SystemContext, registry, username, password string) error { + helpers, err := sysregistriesv2.CredentialHelpersForRegistry(sys, registry) + if err != nil { + return err + } + for _, helper := range helpers { + if err = setAuthToCredHelper(helper, registry, username, password); err != nil { + logrus.Warnf("error storing credentials for %s to the credential helper %s, %v", registry, helper, err) + continue + } + logrus.Debugf("credentials for %s were stored in the credential helper %s", registry, helper) + return nil + } + return modifyJSON(sys, func(auths *dockerConfigFile) (bool, error) { + if ch, exists := auths.CredHelpers[registry]; exists { return false, setAuthToCredHelper(ch, registry, username, password) } @@ -81,46 +96,67 @@ func SetAuthentication(sys *types.SystemContext, registry, username, password st } // GetAllCredentials returns the registry credentials for all registries stored -// in either the auth.json file or the docker/config.json. +// in either the auth.json file or the docker/config.json, and the keyring. func GetAllCredentials(sys *types.SystemContext) (map[string]types.DockerAuthConfig, error) { - // Note: we need to read the auth files in the inverse order to prevent + // Note: we need to update the authConfigs only if the auth for the registry does not exist to prevent // a priority inversion when writing to the map. authConfigs := make(map[string]types.DockerAuthConfig) - paths := getAuthFilePaths(sys, homedir.Get()) - for i := len(paths) - 1; i >= 0; i-- { - path := paths[i] - // readJSONFile returns an empty map in case the path doesn't exist. - auths, err := readJSONFile(path.path, path.legacyFormat) + // query all credentials from the credential helpers + helpers, err := sysregistriesv2.AllConfiguredCredentialHelpers(sys) + if err != nil { + return nil, err + } + for _, helper := range helpers { + creds, err := listAuthFromCredHelper(helper) if err != nil { - return nil, errors.Wrapf(err, "error reading JSON file %q", path.path) + return nil, err } - - for registry, data := range auths.AuthConfigs { - conf, err := decodeDockerAuth(data) + for registry := range creds { + if _, ok := authConfigs[normalizeRegistry(registry)]; ok { + continue + } + conf, err := getAuthFromCredHelper(helper, registry) if err != nil { return nil, err } authConfigs[normalizeRegistry(registry)] = conf } + } + + paths := getAuthFilePaths(sys, homedir.Get()) + for _, path := range paths { + // readJSONFile returns an empty map in case the path doesn't exist. + auths, err := readJSONFile(path.path, path.legacyFormat) + if err != nil { + return nil, errors.Wrapf(err, "error reading JSON file %q", path.path) + } - // Credential helpers may override credentials from the auth file. for registry, credHelper := range auths.CredHelpers { - username, password, err := getAuthFromCredHelper(credHelper, registry) + if _, ok := authConfigs[normalizeRegistry(registry)]; ok { + continue + } + conf, err := getAuthFromCredHelper(credHelper, registry) if err != nil { if credentials.IsErrCredentialsNotFoundMessage(err.Error()) { continue } return nil, err } + authConfigs[normalizeRegistry(registry)] = conf + } - conf := types.DockerAuthConfig{Username: username, Password: password} + for registry, data := range auths.AuthConfigs { + if _, ok := authConfigs[normalizeRegistry(registry)]; ok { + continue + } + conf, err := decodeDockerAuth(data) + if err != nil { + return nil, err + } authConfigs[normalizeRegistry(registry)] = conf } } - // TODO(keyring): if we ever re-enable the keyring support, we had to - // query all credentials from the keyring here. - return authConfigs, nil } @@ -174,6 +210,23 @@ func getCredentialsWithHomeDir(sys *types.SystemContext, registry, homeDir strin return *sys.DockerAuthConfig, nil } + helpers, err := sysregistriesv2.CredentialHelpersForRegistry(sys, registry) + if err != nil { + return types.DockerAuthConfig{}, err + } + for _, helper := range helpers { + creds, err := getAuthFromCredHelper(helper, registry) + if err != nil { + if credentials.IsErrCredentialsNotFoundMessage(err.Error()) { + logrus.Debugf("credentials not found in %s, %v", helper, err) + continue + } + return creds, err + } + logrus.Debugf("Returning credentials from credential helper %s", helper) + return creds, nil + } + if enableKeyring { username, password, err := getAuthFromKernelKeyring(registry) if err == nil { @@ -188,7 +241,7 @@ func getCredentialsWithHomeDir(sys *types.SystemContext, registry, homeDir strin for _, path := range getAuthFilePaths(sys, homeDir) { authConfig, err := findAuthentication(registry, path.path, path.legacyFormat) if err != nil { - logrus.Debugf("Credentials not found") + logrus.Debugf("Credentials not found, %v", err) return types.DockerAuthConfig{}, err } @@ -229,6 +282,24 @@ func getAuthenticationWithHomeDir(sys *types.SystemContext, registry, homeDir st // RemoveAuthentication deletes the credentials stored in auth.json func RemoveAuthentication(sys *types.SystemContext, registry string) error { + helpers, err := sysregistriesv2.CredentialHelpersForRegistry(sys, registry) + if err != nil { + return err + } + for _, helper := range helpers { + if _, err = getAuthFromCredHelper(helper, registry); err != nil { + if credentials.IsErrCredentialsNotFoundMessage(err.Error()) { + logrus.Debugf("Not logged in to %s with credential helper %s", registry, helper) + } + continue + } + if err = deleteAuthFromCredHelper(helper, registry); err != nil { + return err + } + logrus.Debugf("Credentials were deleted from credential helper %s", helper) + return nil + } + return modifyJSON(sys, func(auths *dockerConfigFile) (bool, error) { // First try cred helpers. if ch, exists := auths.CredHelpers[registry]; exists { @@ -258,6 +329,24 @@ func RemoveAuthentication(sys *types.SystemContext, registry string) error { // RemoveAllAuthentication deletes all the credentials stored in auth.json and kernel keyring func RemoveAllAuthentication(sys *types.SystemContext) error { + helpers, err := sysregistriesv2.AllConfiguredCredentialHelpers(sys) + if err != nil { + return err + } + for _, helper := range helpers { + creds, err := listAuthFromCredHelper(helper) + if err != nil { + return err + } + for registry := range creds { + if err = deleteAuthFromCredHelper(helper, registry); err != nil { + logrus.Warningf("error deleting credentials for %s from %s: %v", registry, helper, err) + } else { + logrus.Debugf("Credentials for %s were deleted from credential helper %s", registry, helper) + } + } + } + return modifyJSON(sys, func(auths *dockerConfigFile) (bool, error) { if enableKeyring { err := removeAllAuthFromKernelKeyring() @@ -267,13 +356,26 @@ func RemoveAllAuthentication(sys *types.SystemContext) error { } logrus.Debugf("error removing credentials from kernel keyring") } + for registry, helper := range auths.CredHelpers { + if err = deleteAuthFromCredHelper(helper, registry); err != nil { + logrus.Warningf("error deleting credentials for %s from %s: %v", registry, helper, err) + } else { + logrus.Debugf("Credentials for %s were deleted from credential helper %s", registry, helper) + } + } auths.CredHelpers = make(map[string]string) auths.AuthConfigs = make(map[string]dockerAuthConfig) return true, nil }) } -// getPathToAuth gets the path of the auth.json file used for reading and writing credentials +func listAuthFromCredHelper(credHelper string) (map[string]string, error) { + helperName := fmt.Sprintf("docker-credential-%s", credHelper) + p := helperclient.NewShellProgramFunc(helperName) + return helperclient.List(p) +} + +// getPathToAuth gets the path of the auth.json file used for reading and writting credentials // returns the path, and a bool specifies whether the file is in legacy format func getPathToAuth(sys *types.SystemContext) (string, bool, error) { return getPathToAuthWithOS(sys, runtime.GOOS) @@ -387,14 +489,17 @@ func modifyJSON(sys *types.SystemContext, editor func(auths *dockerConfigFile) ( return nil } -func getAuthFromCredHelper(credHelper, registry string) (string, string, error) { +func getAuthFromCredHelper(credHelper, registry string) (types.DockerAuthConfig, error) { helperName := fmt.Sprintf("docker-credential-%s", credHelper) p := helperclient.NewShellProgramFunc(helperName) creds, err := helperclient.Get(p, registry) if err != nil { - return "", "", err + return types.DockerAuthConfig{}, err } - return creds.Username, creds.Secret, nil + return types.DockerAuthConfig{ + Username: creds.Username, + Password: creds.Secret, + }, nil } func setAuthToCredHelper(credHelper, registry, username, password string) error { @@ -423,15 +528,7 @@ func findAuthentication(registry, path string, legacyFormat bool) (types.DockerA // First try cred helpers. They should always be normalized. if ch, exists := auths.CredHelpers[registry]; exists { - username, password, err := getAuthFromCredHelper(ch, registry) - if err != nil { - return types.DockerAuthConfig{}, err - } - - return types.DockerAuthConfig{ - Username: username, - Password: password, - }, nil + return getAuthFromCredHelper(ch, registry) } // I'm feeling lucky diff --git a/pkg/docker/config/config_test.go b/pkg/docker/config/config_test.go index 13ffeec0a..da2582d90 100644 --- a/pkg/docker/config/config_test.go +++ b/pkg/docker/config/config_test.go @@ -97,6 +97,17 @@ func TestGetAuth(t *testing.T) { os.Setenv("XDG_RUNTIME_DIR", origXDG) }() + // override PATH for executing credHelper + curtDir, err := os.Getwd() + require.NoError(t, err) + origPath := os.Getenv("PATH") + newPath := fmt.Sprintf("%s:%s", filepath.Join(curtDir, "testdata"), origPath) + os.Setenv("PATH", newPath) + t.Logf("using PATH: %q", newPath) + defer func() { + os.Setenv("PATH", origPath) + }() + tmpHomeDir, err := ioutil.TempDir("", "test_docker_client_get_auth") if err != nil { t.Fatal(err) @@ -237,6 +248,17 @@ func TestGetAuth(t *testing.T) { hostname: "https://localhost:5000", path: filepath.Join("testdata", "empty.json"), }, + { + name: "credhelper from registries.conf", + hostname: "registry-a.com", + sys: &types.SystemContext{ + SystemRegistriesConfPath: filepath.Join("testdata", "cred-helper.conf"), + }, + expected: types.DockerAuthConfig{ + Username: "foo", + Password: "bar", + }, + }, } { t.Run(tc.name, func(t *testing.T) { if err := os.RemoveAll(configPath); err != nil { @@ -474,7 +496,22 @@ func TestGetAllCredentials(t *testing.T) { err = tmpFile.Close() require.NoError(t, err) authFilePath := tmpFile.Name() - sys := types.SystemContext{AuthFilePath: authFilePath} + // override PATH for executing credHelper + path, err := os.Getwd() + require.NoError(t, err) + origPath := os.Getenv("PATH") + newPath := fmt.Sprintf("%s:%s", filepath.Join(path, "testdata"), origPath) + os.Setenv("PATH", newPath) + t.Logf("using PATH: %q", newPath) + defer func() { + os.Setenv("PATH", origPath) + }() + err = os.Chmod(filepath.Join(path, "testdata", "docker-credential-helper-registry"), os.ModePerm) + require.NoError(t, err) + sys := types.SystemContext{ + AuthFilePath: authFilePath, + SystemRegistriesConfPath: filepath.Join("testdata", "cred-helper.conf"), + } data := []struct { server string @@ -496,6 +533,11 @@ func TestGetAllCredentials(t *testing.T) { username: "local-user", password: "local-password", }, + { + server: "registry-a.com", + username: "foo", + password: "bar", + }, } // Write the credentials to the authfile. diff --git a/pkg/docker/config/testdata/cred-helper.conf b/pkg/docker/config/testdata/cred-helper.conf new file mode 100644 index 000000000..3409dcd88 --- /dev/null +++ b/pkg/docker/config/testdata/cred-helper.conf @@ -0,0 +1,3 @@ +[[registry]] +credential-helper = "helper-registry" +location = "registry-a.com" diff --git a/pkg/docker/config/testdata/docker-credential-helper-registry b/pkg/docker/config/testdata/docker-credential-helper-registry new file mode 100755 index 000000000..8e5ca8da9 --- /dev/null +++ b/pkg/docker/config/testdata/docker-credential-helper-registry @@ -0,0 +1,18 @@ +#!/bin/bash + +case "${1}" in + get) + read REGISTRY + echo "{\"ServerURL\":\"${REGISTRY}\",\"Username\":\"foo\",\"Secret\":\"bar\"}" + exit 0 + ;; + list) + read UNUSED + echo "{\"registry-a.com\":\"foo\"}" + exit 0 + ;; + *) + echo "not implemented" + exit 1 + ;; +esac \ No newline at end of file diff --git a/pkg/sysregistriesv2/system_registries_v2.go b/pkg/sysregistriesv2/system_registries_v2.go index 3312237ef..93e4a5860 100644 --- a/pkg/sysregistriesv2/system_registries_v2.go +++ b/pkg/sysregistriesv2/system_registries_v2.go @@ -78,6 +78,9 @@ type Registry struct { // effectively be pulled from "example.com/foo/bar/myimage:latest". // If no Prefix is specified, it defaults to the specified location. Prefix string `toml:"prefix"` + // The credential helper for specified registry + // global default CredentialHelpers is used if CredentialHelper is not specified + CredentialHelper string `toml:"credential-helper,omitempty"` // A registry is an Endpoint too Endpoint // The registry's mirrors. @@ -151,7 +154,9 @@ func (config *V1RegistriesConf) Nonempty() bool { // V2RegistriesConf is the sysregistries v2 configuration format. type V2RegistriesConf struct { - Registries []Registry `toml:"registry"` + // The credential store for registries + CredentialHelpers []string `toml:"credential-helpers"` + Registries []Registry `toml:"registry"` // An array of host[:port] (not prefix!) entries to use for resolving unqualified image references UnqualifiedSearchRegistries []string `toml:"unqualified-search-registries"` @@ -301,6 +306,11 @@ func (config *V2RegistriesConf) postProcessRegistries() error { } } + // ignore the credHelper if it is set for inner namespace + if reg.CredentialHelper != "" && strings.Contains(reg.Prefix, "/") { + return &InvalidRegistries{s: fmt.Sprintf("credential-helper can only be configured for registry domain, not for inner namespaces %s", reg.Prefix)} + } + // make sure mirrors are valid for _, mir := range reg.Mirrors { mir.Location, err = parseLocation(mir.Location) @@ -663,6 +673,51 @@ func GetShortNameMode(ctx *types.SystemContext) (types.ShortNameMode, error) { return config.shortNameMode, err } +// AllConfiguredCredentialHelpers returns a list of all credential helpers that are configured for some registry +// Almost all callers should use CredentialHelpersForRegistry to get configuration applicable to a specific registry. +func AllConfiguredCredentialHelpers(sys *types.SystemContext) ([]string, error) { + helpers := make(map[string]bool) + config, err := getConfig(sys) + if err != nil { + return nil, err + } + for _, store := range config.partialV2.CredentialHelpers { + helpers[store] = true + } + for _, reg := range config.partialV2.Registries { + if reg.CredentialHelper != "" { + if ok := helpers[reg.CredentialHelper]; !ok { + helpers[reg.CredentialHelper] = true + } + } + } + + var result []string + for cred := range helpers { + result = append(result, cred) + } + + return result, nil +} + +// CredentialHelpersForRegistry returns a list of credential helpers to try to use for the given registry, +// in the order to try. +func CredentialHelpersForRegistry(sys *types.SystemContext, registry string) ([]string, error) { + config, err := getConfig(sys) + if err != nil { + return nil, err + } + for _, reg := range config.partialV2.Registries { + if reg.Prefix == registry && reg.CredentialHelper != "" { + return []string{reg.CredentialHelper}, nil + } + } + if len(config.partialV2.CredentialHelpers) > 0 { + return config.partialV2.CredentialHelpers, nil + } + return nil, nil +} + // refMatchesPrefix returns true iff ref, // which is a registry, repository namespace, repository or image reference (as formatted by // reference.Domain(), reference.Named.Name() or reference.Reference.String() diff --git a/pkg/sysregistriesv2/system_registries_v2_test.go b/pkg/sysregistriesv2/system_registries_v2_test.go index 763a785e3..1dfd0214a 100644 --- a/pkg/sysregistriesv2/system_registries_v2_test.go +++ b/pkg/sysregistriesv2/system_registries_v2_test.go @@ -636,3 +636,41 @@ func TestGetShortNameMode(t *testing.T) { assert.Equal(t, test.mode, mode, "%s", test.path) } } + +func TestCredentialHelpersForRegistry(t *testing.T) { + ctx := &types.SystemContext{ + SystemRegistriesConfPath: "testdata/cred-helper.conf", + SystemRegistriesConfDirPath: "testdata/registries.conf.d", + } + for _, c := range []struct { + registry string + helper []string + }{ + {"registry-a.com", []string{"helper-a"}}, + {"registry-b.com", []string{"helper-b", "helper"}}, + {"registry-c.com", []string{"helper-c"}}, + {"registry-not-conf.com", []string{"helper-b", "helper"}}, + } { + ret, err := CredentialHelpersForRegistry(ctx, c.registry) + require.NoError(t, err) + assert.ElementsMatch(t, c.helper, ret) + } + + ctx = &types.SystemContext{ + SystemRegistriesConfPath: "testdata/cred-helper-invalid.conf", + SystemRegistriesConfDirPath: "testdata/registries.conf.d", + } + _, err := CredentialHelpersForRegistry(ctx, "registry-c.com") + assert.NotEqual(t, nil, err) +} + +func TestAllConfiguredCredentialHelpers(t *testing.T) { + ctx := &types.SystemContext{ + SystemRegistriesConfPath: "testdata/cred-helper.conf", + SystemRegistriesConfDirPath: "testdata/registries.conf.d", + } + expect := []string{"helper-b", "helper-c", "helper-a", "helper"} + ret, err := AllConfiguredCredentialHelpers(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, expect, ret) +} diff --git a/pkg/sysregistriesv2/testdata/cred-helper-invalid.conf b/pkg/sysregistriesv2/testdata/cred-helper-invalid.conf new file mode 100644 index 000000000..efa28d764 --- /dev/null +++ b/pkg/sysregistriesv2/testdata/cred-helper-invalid.conf @@ -0,0 +1,4 @@ +credential-helpers = ["helper", "helper-c"] +[[registry]] +credential-helper = "helper-c" +location = "registry-c.com/foo" \ No newline at end of file diff --git a/pkg/sysregistriesv2/testdata/cred-helper.conf b/pkg/sysregistriesv2/testdata/cred-helper.conf new file mode 100644 index 000000000..d1c726ce6 --- /dev/null +++ b/pkg/sysregistriesv2/testdata/cred-helper.conf @@ -0,0 +1,12 @@ +credential-helpers = ["helper-b", "helper"] +[[registry]] +credential-helper = "helper-a" +location = "registry-a.com" + +[[registry]] +location = "registry-b.com" + +[[registry]] +credential-helper = "helper-c" +location = "registry-c.com/foo" +prefix = "registry-c.com"