mirror of https://github.com/containers/image.git
Allow editing credentials in Docker config files
... using a new types.SystemContext.DockerCompatAuthFilePath. Signed-off-by: Miloslav Trmač <mitr@redhat.com>
This commit is contained in:
parent
3f1b5ac784
commit
7b94d26523
2
go.mod
2
go.mod
|
|
@ -9,6 +9,7 @@ require (
|
|||
github.com/containers/ocicrypt v1.1.9
|
||||
github.com/containers/storage v1.50.3-0.20231101112703-6e72f11598fb
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20231011164504-785e29786b46
|
||||
github.com/distribution/reference v0.5.0
|
||||
github.com/docker/cli v24.0.0+incompatible
|
||||
github.com/docker/distribution v2.8.3+incompatible
|
||||
github.com/docker/docker v24.0.7+incompatible
|
||||
|
|
@ -65,7 +66,6 @@ require (
|
|||
github.com/coreos/go-oidc/v3 v3.7.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.5.0 // indirect
|
||||
github.com/docker/go-metrics v0.0.1 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -502,6 +503,23 @@ func prepareForEdit(sys *types.SystemContext, key string, keyRelevant bool) ([]s
|
|||
isNamespaced = ns
|
||||
}
|
||||
|
||||
if sys != nil && sys.DockerCompatAuthFilePath != "" {
|
||||
if sys.AuthFilePath != "" {
|
||||
return nil, nil, "", false, errors.New("AuthFilePath and DockerCompatAuthFilePath can not be set simultaneously")
|
||||
}
|
||||
if keyRelevant {
|
||||
if isNamespaced {
|
||||
return nil, nil, "", false, fmt.Errorf("Credentials cannot be recorded in Docker-compatible format with namespaced key %q", key)
|
||||
}
|
||||
if key == "docker.io" {
|
||||
key = "https://index.docker.io/v1/"
|
||||
}
|
||||
}
|
||||
|
||||
// Do not use helpers defined in sysregistriesv2 because Docker isn’t aware of them.
|
||||
return []string{sysregistriesv2.AuthenticationFileHelper}, modifyDockerConfigJSON, key, false, nil
|
||||
}
|
||||
|
||||
helpers, err := sysregistriesv2.CredentialHelpers(sys)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, err
|
||||
|
|
@ -526,9 +544,17 @@ func getPathToAuth(sys *types.SystemContext) (authPath, bool, error) {
|
|||
// it exists only to allow testing it with an artificial runtime.GOOS.
|
||||
func getPathToAuthWithOS(sys *types.SystemContext, goOS string) (authPath, bool, error) {
|
||||
if sys != nil {
|
||||
if sys.AuthFilePath != "" && sys.DockerCompatAuthFilePath != "" {
|
||||
return authPath{}, false, errors.New("AuthFilePath and DockerCompatAuthFilePath can not be set simultaneously")
|
||||
}
|
||||
if sys.AuthFilePath != "" {
|
||||
return newAuthPathDefault(sys.AuthFilePath), true, nil
|
||||
}
|
||||
// When reading, we can process auth.json and Docker’s config.json with the same code.
|
||||
// When writing, prepareForEdit chooses an appropriate jsonEditor implementation.
|
||||
if sys.DockerCompatAuthFilePath != "" {
|
||||
return newAuthPathDefault(sys.DockerCompatAuthFilePath), true, nil
|
||||
}
|
||||
if sys.LegacyFormatAuthFilePath != "" {
|
||||
return authPath{path: sys.LegacyFormatAuthFilePath, legacyFormat: true}, true, nil
|
||||
}
|
||||
|
|
@ -639,6 +665,86 @@ func modifyJSON(sys *types.SystemContext, editor func(fileContents *dockerConfig
|
|||
return description, nil
|
||||
}
|
||||
|
||||
// modifyDockerConfigJSON finds a docker config.json file, calls editor on the contents, and
|
||||
// writes it back if editor returns true.
|
||||
// Returns a human-readable description of the file, to be returned by SetCredentials.
|
||||
//
|
||||
// The editor may also return a human-readable description of the updated location; if it is "",
|
||||
// the file itself is used.
|
||||
func modifyDockerConfigJSON(sys *types.SystemContext, editor func(fileContents *dockerConfigFile) (bool, string, error)) (string, error) {
|
||||
if sys == nil || sys.DockerCompatAuthFilePath == "" {
|
||||
return "", errors.New("internal error: modifyDockerConfigJSON called with DockerCompatAuthFilePath not set")
|
||||
}
|
||||
path := sys.DockerCompatAuthFilePath
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Try hard not to clobber fields we don’t understand, even fields which may be added in future Docker versions.
|
||||
var rawContents map[string]json.RawMessage
|
||||
originalBytes, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
if err := json.Unmarshal(originalBytes, &rawContents); err != nil {
|
||||
return "", fmt.Errorf("unmarshaling JSON at %q: %w", path, err)
|
||||
}
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
rawContents = map[string]json.RawMessage{}
|
||||
default: // err != nil
|
||||
return "", err
|
||||
}
|
||||
|
||||
syntheticContents := dockerConfigFile{
|
||||
AuthConfigs: map[string]dockerAuthConfig{},
|
||||
CredHelpers: map[string]string{},
|
||||
}
|
||||
// json.Unmarshal also falls back to case-insensitive field matching; this code does not do that. Presumably
|
||||
// config.json is mostly maintained by machines doing `docker login`, so the files should, hopefully, not contain field names with
|
||||
// unexpected case.
|
||||
if rawAuths, ok := rawContents["auths"]; ok {
|
||||
// This conversion will lose fields we don’t know about; when updating an entry, we can’t tell whether an unknown field
|
||||
// should be preserved or discarded (because it is made obsolete/unwanted with the new credentials).
|
||||
// It might make sense to track which entries of "auths" we actually modified, and to not touch any others.
|
||||
if err := json.Unmarshal(rawAuths, &syntheticContents.AuthConfigs); err != nil {
|
||||
return "", fmt.Errorf(`unmarshaling "auths" in JSON at %q: %w`, path, err)
|
||||
}
|
||||
}
|
||||
if rawCH, ok := rawContents["credHelpers"]; ok {
|
||||
if err := json.Unmarshal(rawCH, &syntheticContents.CredHelpers); err != nil {
|
||||
return "", fmt.Errorf(`unmarshaling "credHelpers" in JSON at %q: %w`, path, err)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
updated, description, err := editor(&syntheticContents)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("updating %q: %w", path, err)
|
||||
}
|
||||
if updated {
|
||||
rawAuths, err := json.MarshalIndent(syntheticContents.AuthConfigs, "", "\t")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshaling JSON %q: %w", path, err)
|
||||
}
|
||||
rawContents["auths"] = rawAuths
|
||||
// We never modify syntheticContents.CredHelpers, so we don’t need to update it.
|
||||
newData, err := json.MarshalIndent(rawContents, "", "\t")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshaling JSON %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err = ioutils.AtomicWriteFile(path, newData, 0600); err != nil {
|
||||
return "", fmt.Errorf("writing to file %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if description == "" {
|
||||
description = path
|
||||
}
|
||||
return description, nil
|
||||
}
|
||||
|
||||
func getCredsFromCredHelper(credHelper, registry string) (types.DockerAuthConfig, error) {
|
||||
helperName := fmt.Sprintf("docker-credential-%s", credHelper)
|
||||
p := helperclient.NewShellProgramFunc(helperName)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
"github.com/containers/image/v5/docker/reference"
|
||||
"github.com/containers/image/v5/types"
|
||||
dockerReference "github.com/distribution/reference"
|
||||
"github.com/docker/cli/cli/config"
|
||||
configtypes "github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/docker/registry"
|
||||
|
|
@ -471,7 +472,7 @@ func TestGetCredentialsInteroperability(t *testing.T) {
|
|||
configPath := filepath.Join(configDir, config.ConfigFileName)
|
||||
|
||||
// Initially, there are no credentials
|
||||
creds, err := GetCredentials(&types.SystemContext{AuthFilePath: configPath}, c.queryKey)
|
||||
creds, err := GetCredentials(&types.SystemContext{DockerCompatAuthFilePath: configPath}, c.queryKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, types.DockerAuthConfig{}, creds)
|
||||
|
||||
|
|
@ -489,7 +490,7 @@ func TestGetCredentialsInteroperability(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
// We can find the credentials.
|
||||
creds, err = GetCredentials(&types.SystemContext{AuthFilePath: configPath}, c.queryKey)
|
||||
creds, err = GetCredentials(&types.SystemContext{DockerCompatAuthFilePath: configPath}, c.queryKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, types.DockerAuthConfig{
|
||||
Username: testUser,
|
||||
|
|
@ -512,7 +513,7 @@ func TestGetCredentialsInteroperability(t *testing.T) {
|
|||
}
|
||||
require.True(t, succeeded)
|
||||
// We can’t find the credentials any more.
|
||||
creds, err = GetCredentials(&types.SystemContext{AuthFilePath: configPath}, c.queryKey)
|
||||
creds, err = GetCredentials(&types.SystemContext{DockerCompatAuthFilePath: configPath}, c.queryKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, types.DockerAuthConfig{}, creds)
|
||||
}
|
||||
|
|
@ -858,6 +859,95 @@ func TestRemoveAuthentication(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSetCredentialsInteroperability verifies that our config files can be consumed by Docker.
|
||||
func TestSetCredentialsInteroperability(t *testing.T) {
|
||||
const testUser = "some-user"
|
||||
const testPassword = "some-password"
|
||||
|
||||
for _, c := range []struct {
|
||||
loginKey string // or "" for Docker's default. We must special-case that because (docker login docker.io) works, but (docker logout docker.io) doesn't!
|
||||
queryRepo string
|
||||
otherContents bool
|
||||
loginKeyError bool
|
||||
}{
|
||||
{loginKey: "example.com", queryRepo: "example.com/ns/repo"},
|
||||
{loginKey: "example.com:8000", queryRepo: "example.com:8000/ns/repo"},
|
||||
{loginKey: "docker.io", queryRepo: "docker.io/library/busybox"},
|
||||
{loginKey: "docker.io", queryRepo: "docker.io/notlibrary/busybox"},
|
||||
{loginKey: "example.com", queryRepo: "example.com/ns/repo", otherContents: true},
|
||||
{loginKey: "example.com/ns", queryRepo: "example.com/ns/repo", loginKeyError: true},
|
||||
{loginKey: "example.com:8000/ns", queryRepo: "example.com:8000/ns/repo", loginKeyError: true},
|
||||
} {
|
||||
configDir := t.TempDir()
|
||||
configPath := filepath.Join(configDir, config.ConfigFileName)
|
||||
|
||||
// The credential lookups are intended to match github.com/docker/cli/command/image.RunPull .
|
||||
dockerRef, err := dockerReference.ParseNormalizedNamed(c.queryRepo)
|
||||
require.NoError(t, err)
|
||||
dockerRef = dockerReference.TagNameOnly(dockerRef)
|
||||
repoInfo, err := registry.ParseRepositoryInfo(dockerRef)
|
||||
require.NoError(t, err)
|
||||
configKey := repoInfo.Index.Name
|
||||
if repoInfo.Index.Official {
|
||||
configKey = registry.IndexServer
|
||||
}
|
||||
|
||||
if c.otherContents {
|
||||
err := os.WriteFile(configPath, []byte(`{"auths":{"unmodified-domain.example":{"identitytoken":"identity"}},`+
|
||||
`"psFormat":"psFormatValue",`+
|
||||
`"credHelpers":{"helper-domain.example":"helper-name"}`+
|
||||
`}`), 0o700)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Initially, there are no credentials
|
||||
configFile, err := config.Load(configDir)
|
||||
require.NoError(t, err)
|
||||
creds, err := configFile.GetCredentialsStore(configKey).Get(configKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configtypes.AuthConfig{}, creds)
|
||||
|
||||
// Log in.
|
||||
_, err = SetCredentials(&types.SystemContext{DockerCompatAuthFilePath: configPath}, c.loginKey, testUser, testPassword)
|
||||
if c.loginKeyError {
|
||||
assert.Error(t, err)
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err)
|
||||
// We can find the credentials.
|
||||
configFile, err = config.Load(configDir)
|
||||
require.NoError(t, err)
|
||||
creds, err = configFile.GetCredentialsStore(configKey).Get(configKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configtypes.AuthConfig{
|
||||
ServerAddress: configKey,
|
||||
Username: testUser,
|
||||
Password: testPassword,
|
||||
}, creds)
|
||||
|
||||
// Log out.
|
||||
err = RemoveAuthentication(&types.SystemContext{DockerCompatAuthFilePath: configPath}, c.loginKey)
|
||||
require.NoError(t, err)
|
||||
// We can’t find the credentials any more.
|
||||
configFile, err = config.Load(configDir)
|
||||
require.NoError(t, err)
|
||||
creds, err = configFile.GetCredentialsStore(configKey).Get(configKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configtypes.AuthConfig{}, creds)
|
||||
|
||||
if c.otherContents {
|
||||
creds, err = configFile.GetCredentialsStore("unmodified-domain.example").Get("unmodified-domain.example")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configtypes.AuthConfig{
|
||||
ServerAddress: "unmodified-domain.example",
|
||||
IdentityToken: "identity",
|
||||
}, creds)
|
||||
assert.Equal(t, "psFormatValue", configFile.PsFormat)
|
||||
assert.Equal(t, map[string]string{"helper-domain.example": "helper-name"}, configFile.CredentialHelpers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateKey(t *testing.T) {
|
||||
// Invalid keys
|
||||
for _, key := range []string{
|
||||
|
|
|
|||
|
|
@ -594,6 +594,10 @@ type SystemContext struct {
|
|||
// this field is ignored if `AuthFilePath` is set (we favor the newer format);
|
||||
// only reading of this data is supported;
|
||||
LegacyFormatAuthFilePath string
|
||||
// If set, a path to a Docker-compatible "config.json" file containing credentials; and no other files are processed.
|
||||
// This must not be set if AuthFilePath is set.
|
||||
// Only credentials and credential helpers in this file apre processed, not any other configuration in this file.
|
||||
DockerCompatAuthFilePath string
|
||||
// If not "", overrides the use of platform.GOARCH when choosing an image or verifying architecture match.
|
||||
ArchitectureChoice string
|
||||
// If not "", overrides the use of platform.GOOS when choosing an image or verifying OS match.
|
||||
|
|
|
|||
Loading…
Reference in New Issue