From f28367e1ae1e53d09ce852c3eb853f6c5ae4f940 Mon Sep 17 00:00:00 2001 From: umohnani8 Date: Tue, 29 Aug 2017 10:54:45 -0400 Subject: [PATCH] Add docker/config package to containers/image/pkg This package is used in authenticating a user for kpod login and can be used for authentication in kpod push, pull etc. Signed-off-by: umohnani8 --- docker/docker_client.go | 276 ++++++++++++------------- docker/docker_client_test.go | 376 +++++++++++++++++++---------------- docker/docker_image_dest.go | 2 +- docker/docker_image_src.go | 4 +- oci/archive/oci_dest.go | 1 - pkg/docker/config/config.go | 295 +++++++++++++++++++++++++++ types/types.go | 2 + 7 files changed, 632 insertions(+), 324 deletions(-) create mode 100644 pkg/docker/config/config.go diff --git a/docker/docker_client.go b/docker/docker_client.go index 511490407f..24b82d6f13 100644 --- a/docker/docker_client.go +++ b/docker/docker_client.go @@ -3,7 +3,6 @@ package docker import ( "context" "crypto/tls" - "encoding/base64" "encoding/json" "fmt" "io" @@ -15,11 +14,10 @@ import ( "time" "github.com/containers/image/docker/reference" + "github.com/containers/image/pkg/docker/config" "github.com/containers/image/pkg/tlsclientconfig" "github.com/containers/image/types" - "github.com/containers/storage/pkg/homedir" "github.com/docker/distribution/registry/client" - helperclient "github.com/docker/docker-credential-helpers/client" "github.com/docker/go-connections/tlsconfig" "github.com/opencontainers/go-digest" "github.com/pkg/errors" @@ -27,13 +25,8 @@ import ( ) const ( - dockerHostname = "docker.io" - dockerRegistry = "registry-1.docker.io" - dockerAuthRegistry = "https://index.docker.io/v1/" - - dockerCfg = ".docker" - dockerCfgFileName = "config.json" - dockerCfgObsolete = ".dockercfg" + dockerHostname = "docker.io" + dockerRegistry = "registry-1.docker.io" systemPerHostCertDirPath = "/etc/docker/certs.d" @@ -51,9 +44,13 @@ const ( extensionSignatureTypeAtomic = "atomic" // extensionSignature.Type ) -// ErrV1NotSupported is returned when we're trying to talk to a -// docker V1 registry. -var ErrV1NotSupported = errors.New("can't talk to a V1 docker registry") +var ( + // ErrV1NotSupported is returned when we're trying to talk to a + // docker V1 registry. + ErrV1NotSupported = errors.New("can't talk to a V1 docker registry") + // ErrUnauthorizedForCredentials is returned when the status code returned is 401 + ErrUnauthorizedForCredentials = errors.New("unable to retrieve auth token: invalid username/password") +) // extensionSignature and extensionSignatureList come from github.com/openshift/origin/pkg/dockerregistry/server/signaturedispatcher.go: // signature represents a Docker image signature. @@ -128,52 +125,147 @@ func dockerCertDir(ctx *types.SystemContext, hostPort string) string { return filepath.Join(hostCertDir, hostPort) } -// newDockerClient returns a new dockerClient instance for refHostname (a host a specified in the Docker image reference, not canonicalized to dockerRegistry) +func setupCertificates(dir string, tlsc *tls.Config) error { + logrus.Debugf("Looking for TLS certificates and private keys in %s", dir) + fs, err := ioutil.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + if os.IsPermission(err) { + logrus.Debugf("Skipping scan of %s due to permission error: %v", dir, err) + return nil + } + return err + } + + for _, f := range fs { + fullPath := filepath.Join(dir, f.Name()) + if strings.HasSuffix(f.Name(), ".crt") { + systemPool, err := tlsconfig.SystemCertPool() + if err != nil { + return errors.Wrap(err, "unable to get system cert pool") + } + tlsc.RootCAs = systemPool + logrus.Debugf(" crt: %s", fullPath) + data, err := ioutil.ReadFile(fullPath) + if err != nil { + return err + } + tlsc.RootCAs.AppendCertsFromPEM(data) + } + if strings.HasSuffix(f.Name(), ".cert") { + certName := f.Name() + keyName := certName[:len(certName)-5] + ".key" + logrus.Debugf(" cert: %s", fullPath) + if !hasFile(fs, keyName) { + return errors.Errorf("missing key %s for client certificate %s. Note that CA certificates should use the extension .crt", keyName, certName) + } + cert, err := tls.LoadX509KeyPair(filepath.Join(dir, certName), filepath.Join(dir, keyName)) + if err != nil { + return err + } + tlsc.Certificates = append(tlsc.Certificates, cert) + } + if strings.HasSuffix(f.Name(), ".key") { + keyName := f.Name() + certName := keyName[:len(keyName)-4] + ".cert" + logrus.Debugf(" key: %s", fullPath) + if !hasFile(fs, certName) { + return errors.Errorf("missing client certificate %s for key %s", certName, keyName) + } + } + } + return nil +} + +func hasFile(files []os.FileInfo, name string) bool { + for _, f := range files { + if f.Name() == name { + return true + } + } + return false +} + +// newDockerClientFromRef returns a new dockerClient instance for refHostname (a host a specified in the Docker image reference, not canonicalized to dockerRegistry) // “write” specifies whether the client will be used for "write" access (in particular passed to lookaside.go:toplevelFromSection) -func newDockerClient(ctx *types.SystemContext, ref dockerReference, write bool, actions string) (*dockerClient, error) { +func newDockerClientFromRef(ctx *types.SystemContext, ref dockerReference, write bool, actions string) (*dockerClient, error) { registry := reference.Domain(ref.ref) - if registry == dockerHostname { - registry = dockerRegistry + username, password, err := config.GetAuthentication(ctx, reference.Domain(ref.ref)) + if err != nil { + return nil, errors.Wrapf(err, "error getting username and password") } - username, password, err := getAuth(ctx, reference.Domain(ref.ref)) + sigBase, err := configuredSignatureStorageBase(ctx, ref, write) if err != nil { return nil, err } + remoteName := reference.Path(ref.ref) + + return newDockerClientWithDetails(ctx, registry, username, password, actions, sigBase, remoteName) +} + +// newDockerClientWithDetails returns a new dockerClient instance for the given parameters +func newDockerClientWithDetails(ctx *types.SystemContext, registry, username, password, actions string, sigBase signatureStorageBase, remoteName string) (*dockerClient, error) { + hostName := registry + if registry == dockerHostname { + registry = dockerRegistry + } tr := tlsclientconfig.NewTransport() tr.TLSClientConfig = serverDefault() + // It is undefined whether the host[:port] string for dockerHostname should be dockerHostname or dockerRegistry, // because docker/docker does not read the certs.d subdirectory at all in that case. We use the user-visible // dockerHostname here, because it is more symmetrical to read the configuration in that case as well, and because // generally the UI hides the existence of the different dockerRegistry. But note that this behavior is // undocumented and may change if docker/docker changes. - certDir := dockerCertDir(ctx, reference.Domain(ref.ref)) + certDir := dockerCertDir(ctx, hostName) if err := tlsclientconfig.SetupCertificates(certDir, tr.TLSClientConfig); err != nil { return nil, err } + if ctx != nil && ctx.DockerInsecureSkipTLSVerify { tr.TLSClientConfig.InsecureSkipVerify = true } - client := &http.Client{Transport: tr} - - sigBase, err := configuredSignatureStorageBase(ctx, ref, write) - if err != nil { - return nil, err - } return &dockerClient{ ctx: ctx, registry: registry, username: username, password: password, - client: client, + client: &http.Client{Transport: tr}, signatureBase: sigBase, scope: authScope{ actions: actions, - remoteName: reference.Path(ref.ref), + remoteName: remoteName, }, }, nil } +// CheckAuth validates the credentials by attempting to log into the registry +// returns an error if an error occcured while making the http request or the status code received was 401 +func CheckAuth(ctx context.Context, sCtx *types.SystemContext, username, password, registry string) error { + newLoginClient, err := newDockerClientWithDetails(sCtx, registry, username, password, "", nil, "") + if err != nil { + return errors.Wrapf(err, "error creating new docker client") + } + + resp, err := newLoginClient.makeRequest(ctx, "GET", "/v2/", nil, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return nil + case http.StatusUnauthorized: + return ErrUnauthorizedForCredentials + default: + return errors.Errorf("error occured with status code %q", resp.StatusCode) + } +} + // makeRequest creates and executes a http.Request with the specified parameters, adding authentication and TLS options for the Docker client. // The host name and schema is taken from the client or autodetected, and the path is relative to it, i.e. the path usually starts with /v2/. func (c *dockerClient) makeRequest(ctx context.Context, method, path string, headers map[string][]string, stream io.Reader) (*http.Response, error) { @@ -245,7 +337,10 @@ func (c *dockerClient) setupRequestAuth(req *http.Request) error { return errors.Errorf("missing realm in bearer auth challenge") } service, _ := challenge.Parameters["service"] // Will be "" if not present - scope := fmt.Sprintf("repository:%s:%s", c.scope.remoteName, c.scope.actions) + var scope string + if c.scope.remoteName != "" && c.scope.actions != "" { + scope = fmt.Sprintf("repository:%s:%s", c.scope.remoteName, c.scope.actions) + } token, err := c.getBearerToken(req.Context(), realm, service, scope) if err != nil { return err @@ -291,7 +386,7 @@ func (c *dockerClient) getBearerToken(ctx context.Context, realm, service, scope defer res.Body.Close() switch res.StatusCode { case http.StatusUnauthorized: - return nil, errors.Errorf("unable to retrieve auth token: 401 unauthorized") + return nil, ErrUnauthorizedForCredentials case http.StatusOK: break default: @@ -315,65 +410,6 @@ func (c *dockerClient) getBearerToken(ctx context.Context, realm, service, scope return &token, nil } -func getAuth(ctx *types.SystemContext, registry string) (string, string, error) { - if ctx != nil && ctx.DockerAuthConfig != nil { - return ctx.DockerAuthConfig.Username, ctx.DockerAuthConfig.Password, nil - } - var dockerAuth dockerConfigFile - dockerCfgPath := filepath.Join(getDefaultConfigDir(".docker"), dockerCfgFileName) - if _, err := os.Stat(dockerCfgPath); err == nil { - j, err := ioutil.ReadFile(dockerCfgPath) - if err != nil { - return "", "", err - } - if err := json.Unmarshal(j, &dockerAuth); err != nil { - return "", "", err - } - - } else if os.IsNotExist(err) { - // try old config path - oldDockerCfgPath := filepath.Join(getDefaultConfigDir(dockerCfgObsolete)) - if _, err := os.Stat(oldDockerCfgPath); err != nil { - if os.IsNotExist(err) { - return "", "", nil - } - return "", "", errors.Wrap(err, oldDockerCfgPath) - } - - j, err := ioutil.ReadFile(oldDockerCfgPath) - if err != nil { - return "", "", err - } - if err := json.Unmarshal(j, &dockerAuth.AuthConfigs); err != nil { - return "", "", err - } - - } else if err != nil { - return "", "", errors.Wrap(err, dockerCfgPath) - } - - // First try cred helpers. They should always be normalized. - if ch, exists := dockerAuth.CredHelpers[registry]; exists { - return getAuthFromCredHelper(ch, registry) - } - - // I'm feeling lucky. - if c, exists := dockerAuth.AuthConfigs[registry]; exists { - return decodeDockerAuth(c.Auth) - } - - // bad luck; let's normalize the entries first - registry = normalizeRegistry(registry) - normalizedAuths := map[string]dockerAuthConfig{} - for k, v := range dockerAuth.AuthConfigs { - normalizedAuths[normalizeRegistry(k)] = v - } - if c, exists := normalizedAuths[registry]; exists { - return decodeDockerAuth(c.Auth) - } - return "", "", nil -} - // detectProperties detects various properties of the registry. // See the dockerClient documentation for members which are affected by this. func (c *dockerClient) detectProperties(ctx context.Context) error { @@ -456,67 +492,3 @@ func (c *dockerClient) getExtensionsSignatures(ctx context.Context, ref dockerRe } return &parsedBody, nil } - -func getDefaultConfigDir(confPath string) string { - return filepath.Join(homedir.Get(), confPath) -} - -type dockerAuthConfig struct { - Auth string `json:"auth,omitempty"` -} - -type dockerConfigFile struct { - AuthConfigs map[string]dockerAuthConfig `json:"auths"` - CredHelpers map[string]string `json:"credHelpers,omitempty"` -} - -func getAuthFromCredHelper(credHelper, registry string) (string, string, error) { - helperName := fmt.Sprintf("docker-credential-%s", credHelper) - p := helperclient.NewShellProgramFunc(helperName) - creds, err := helperclient.Get(p, registry) - if err != nil { - return "", "", err - } - - return creds.Username, creds.Secret, nil -} - -func decodeDockerAuth(s string) (string, string, error) { - decoded, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return "", "", err - } - parts := strings.SplitN(string(decoded), ":", 2) - if len(parts) != 2 { - // if it's invalid just skip, as docker does - return "", "", nil - } - user := parts[0] - password := strings.Trim(parts[1], "\x00") - return user, password, nil -} - -// convertToHostname converts a registry url which has http|https prepended -// to just an hostname. -// Copied from github.com/docker/docker/registry/auth.go -func convertToHostname(url string) string { - stripped := url - if strings.HasPrefix(url, "http://") { - stripped = strings.TrimPrefix(url, "http://") - } else if strings.HasPrefix(url, "https://") { - stripped = strings.TrimPrefix(url, "https://") - } - - nameParts := strings.SplitN(stripped, "/", 2) - - return nameParts[0] -} - -func normalizeRegistry(registry string) string { - normalized := convertToHostname(registry) - switch normalized { - case "registry-1.docker.io", "docker.io": - return "index.docker.io" - } - return normalized -} diff --git a/docker/docker_client_test.go b/docker/docker_client_test.go index a063a540f7..73ece21613 100644 --- a/docker/docker_client_test.go +++ b/docker/docker_client_test.go @@ -3,15 +3,16 @@ package docker import ( "encoding/base64" "encoding/json" - //"fmt" "io/ioutil" "os" "path/filepath" "reflect" "testing" + "github.com/containers/image/pkg/docker/config" "github.com/containers/image/types" "github.com/containers/storage/pkg/homedir" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) @@ -88,173 +89,196 @@ func TestDockerCertDir(t *testing.T) { } func TestGetAuth(t *testing.T) { + origXDG := os.Getenv("XDG_RUNTIME_DIR") + tmpDir1, err := ioutil.TempDir("", "test_docker_client_get_auth") + if err != nil { + t.Fatal(err) + } + t.Logf("using temporary XDG_RUNTIME_DIR directory: %q", tmpDir1) + // override XDG_RUNTIME_DIR + os.Setenv("XDG_RUNTIME_DIR", tmpDir1) + defer func() { + err := os.RemoveAll(tmpDir1) + if err != nil { + t.Logf("failed to cleanup temporary home directory %q: %v", tmpDir1, err) + } + os.Setenv("XDG_RUNTIME_DIR", origXDG) + }() + origHomeDir := homedir.Get() - tmpDir, err := ioutil.TempDir("", "test_docker_client_get_auth") + tmpDir2, err := ioutil.TempDir("", "test_docker_client_get_auth") if err != nil { t.Fatal(err) } - t.Logf("using temporary home directory: %q", tmpDir) - // override homedir - os.Setenv(homedir.Key(), tmpDir) + t.Logf("using temporary home directory: %q", tmpDir2) + //override homedir + os.Setenv(homedir.Key(), tmpDir2) defer func() { - err := os.RemoveAll(tmpDir) + err := os.RemoveAll(tmpDir2) if err != nil { - t.Logf("failed to cleanup temporary home directory %q: %v", tmpDir, err) + t.Logf("failed to cleanup temporary home directory %q: %v", tmpDir2, err) } os.Setenv(homedir.Key(), origHomeDir) }() - configDir := filepath.Join(tmpDir, ".docker") - if err := os.Mkdir(configDir, 0750); err != nil { + configDir1 := filepath.Join(tmpDir1, "containers") + if err := os.MkdirAll(configDir1, 0700); err != nil { + t.Fatal(err) + } + configDir2 := filepath.Join(tmpDir2, ".docker") + if err := os.MkdirAll(configDir2, 0700); err != nil { t.Fatal(err) } - configPath := filepath.Join(configDir, "config.json") + configPaths := [2]string{filepath.Join(configDir1, "auth.json"), filepath.Join(configDir2, "config.json")} - for _, tc := range []struct { - name string - hostname string - authConfig testAuthConfig - expectedUsername string - expectedPassword string - expectedError error - ctx *types.SystemContext - }{ - { - name: "empty hostname", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{"localhost:5000": testAuthConfigData{"bob", "password"}}), - }, - { - name: "no auth config", - hostname: "index.docker.io", - }, - { - name: "match one", - hostname: "example.org", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{"example.org": testAuthConfigData{"joe", "mypass"}}), - expectedUsername: "joe", - expectedPassword: "mypass", - }, - { - name: "match none", - hostname: "registry.example.org", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{"example.org": testAuthConfigData{"joe", "mypass"}}), - }, - { - name: "match docker.io", - hostname: "docker.io", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{ - "example.org": testAuthConfigData{"example", "org"}, - "index.docker.io": testAuthConfigData{"index", "docker.io"}, - "docker.io": testAuthConfigData{"docker", "io"}, - }), - expectedUsername: "docker", - expectedPassword: "io", - }, - { - name: "match docker.io normalized", - hostname: "docker.io", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{ - "example.org": testAuthConfigData{"bob", "pw"}, - "https://index.docker.io/v1": testAuthConfigData{"alice", "wp"}, - }), - expectedUsername: "alice", - expectedPassword: "wp", - }, - { - name: "normalize registry", - hostname: "https://docker.io/v1", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{ - "docker.io": testAuthConfigData{"user", "pw"}, - "localhost:5000": testAuthConfigData{"joe", "pass"}, - }), - expectedUsername: "user", - expectedPassword: "pw", - }, - { - name: "match localhost", - hostname: "http://localhost", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{ - "docker.io": testAuthConfigData{"user", "pw"}, - "localhost": testAuthConfigData{"joe", "pass"}, - "example.com": testAuthConfigData{"alice", "pwd"}, - }), - expectedUsername: "joe", - expectedPassword: "pass", - }, - { - name: "match ip", - hostname: "10.10.3.56:5000", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{ - "10.10.30.45": testAuthConfigData{"user", "pw"}, - "localhost": testAuthConfigData{"joe", "pass"}, - "10.10.3.56": testAuthConfigData{"alice", "pwd"}, - "10.10.3.56:5000": testAuthConfigData{"me", "mine"}, - }), - expectedUsername: "me", - expectedPassword: "mine", - }, - { - name: "match port", - hostname: "https://localhost:5000", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{ - "https://127.0.0.1:5000": testAuthConfigData{"user", "pw"}, - "http://localhost": testAuthConfigData{"joe", "pass"}, - "https://localhost:5001": testAuthConfigData{"alice", "pwd"}, - "localhost:5000": testAuthConfigData{"me", "mine"}, - }), - expectedUsername: "me", - expectedPassword: "mine", - }, - { - name: "use system context", - hostname: "example.org", - authConfig: makeTestAuthConfig(testAuthConfigDataMap{ - "example.org": testAuthConfigData{"user", "pw"}, - }), - expectedUsername: "foo", - expectedPassword: "bar", - ctx: &types.SystemContext{ - DockerAuthConfig: &types.DockerAuthConfig{ - Username: "foo", - Password: "bar", + for _, configPath := range configPaths { + for _, tc := range []struct { + name string + hostname string + authConfig testAuthConfig + expectedUsername string + expectedPassword string + expectedError error + ctx *types.SystemContext + }{ + { + name: "empty hostname", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{"localhost:5000": testAuthConfigData{"bob", "password"}}), + }, + { + name: "no auth config", + hostname: "index.docker.io", + }, + { + name: "match one", + hostname: "example.org", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{"example.org": testAuthConfigData{"joe", "mypass"}}), + expectedUsername: "joe", + expectedPassword: "mypass", + }, + { + name: "match none", + hostname: "registry.example.org", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{"example.org": testAuthConfigData{"joe", "mypass"}}), + }, + { + name: "match docker.io", + hostname: "docker.io", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{ + "example.org": testAuthConfigData{"example", "org"}, + "index.docker.io": testAuthConfigData{"index", "docker.io"}, + "docker.io": testAuthConfigData{"docker", "io"}, + }), + expectedUsername: "docker", + expectedPassword: "io", + }, + { + name: "match docker.io normalized", + hostname: "docker.io", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{ + "example.org": testAuthConfigData{"bob", "pw"}, + "https://index.docker.io/v1": testAuthConfigData{"alice", "wp"}, + }), + expectedUsername: "alice", + expectedPassword: "wp", + }, + { + name: "normalize registry", + hostname: "https://docker.io/v1", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{ + "docker.io": testAuthConfigData{"user", "pw"}, + "localhost:5000": testAuthConfigData{"joe", "pass"}, + }), + expectedUsername: "user", + expectedPassword: "pw", + }, + { + name: "match localhost", + hostname: "http://localhost", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{ + "docker.io": testAuthConfigData{"user", "pw"}, + "localhost": testAuthConfigData{"joe", "pass"}, + "example.com": testAuthConfigData{"alice", "pwd"}, + }), + expectedUsername: "joe", + expectedPassword: "pass", + }, + { + name: "match ip", + hostname: "10.10.3.56:5000", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{ + "10.10.30.45": testAuthConfigData{"user", "pw"}, + "localhost": testAuthConfigData{"joe", "pass"}, + "10.10.3.56": testAuthConfigData{"alice", "pwd"}, + "10.10.3.56:5000": testAuthConfigData{"me", "mine"}, + }), + expectedUsername: "me", + expectedPassword: "mine", + }, + { + name: "match port", + hostname: "https://localhost:5000", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{ + "https://127.0.0.1:5000": testAuthConfigData{"user", "pw"}, + "http://localhost": testAuthConfigData{"joe", "pass"}, + "https://localhost:5001": testAuthConfigData{"alice", "pwd"}, + "localhost:5000": testAuthConfigData{"me", "mine"}, + }), + expectedUsername: "me", + expectedPassword: "mine", + }, + { + name: "use system context", + hostname: "example.org", + authConfig: makeTestAuthConfig(testAuthConfigDataMap{ + "example.org": testAuthConfigData{"user", "pw"}, + }), + expectedUsername: "foo", + expectedPassword: "bar", + ctx: &types.SystemContext{ + DockerAuthConfig: &types.DockerAuthConfig{ + Username: "foo", + Password: "bar", + }, }, }, - }, - } { - contents, err := json.MarshalIndent(&tc.authConfig, "", " ") - if err != nil { - t.Errorf("[%s] failed to marshal authConfig: %v", tc.name, err) - continue - } - if err := ioutil.WriteFile(configPath, contents, 0640); err != nil { - t.Errorf("[%s] failed to write file %q: %v", tc.name, configPath, err) - continue - } + } { + contents, err := json.MarshalIndent(&tc.authConfig, "", " ") + if err != nil { + t.Errorf("[%s] failed to marshal authConfig: %v", tc.name, err) + continue + } + if err := ioutil.WriteFile(configPath, contents, 0640); err != nil { + t.Errorf("[%s] failed to write file %q: %v", tc.name, configPath, err) + continue + } - var ctx *types.SystemContext - if tc.ctx != nil { - ctx = tc.ctx - } - username, password, err := getAuth(ctx, tc.hostname) - if err == nil && tc.expectedError != nil { - t.Errorf("[%s] got unexpected non error and username=%q, password=%q", tc.name, username, password) - continue - } - if err != nil && tc.expectedError == nil { - t.Errorf("[%s] got unexpected error: %#+v", tc.name, err) - continue - } - if !reflect.DeepEqual(err, tc.expectedError) { - t.Errorf("[%s] got unexpected error: %#+v != %#+v", tc.name, err, tc.expectedError) - continue - } + var ctx *types.SystemContext + if tc.ctx != nil { + ctx = tc.ctx + } + username, password, err := config.GetAuthentication(ctx, tc.hostname) + if err == nil && tc.expectedError != nil { + t.Errorf("[%s] got unexpected non error and username=%q, password=%q", tc.name, username, password) + continue + } + if err != nil && tc.expectedError == nil { + t.Errorf("[%s] got unexpected error: %#+v", tc.name, err) + continue + } + if !reflect.DeepEqual(err, tc.expectedError) { + t.Errorf("[%s] got unexpected error: %#+v != %#+v", tc.name, err, tc.expectedError) + continue + } - if username != tc.expectedUsername { - t.Errorf("[%s] got unexpected user name: %q != %q", tc.name, username, tc.expectedUsername) - } - if password != tc.expectedPassword { - t.Errorf("[%s] got unexpected user name: %q != %q", tc.name, password, tc.expectedPassword) + if username != tc.expectedUsername { + t.Errorf("[%s] got unexpected user name: %q != %q", tc.name, username, tc.expectedUsername) + } + if password != tc.expectedPassword { + t.Errorf("[%s] got unexpected user name: %q != %q", tc.name, password, tc.expectedPassword) + } } + os.RemoveAll(configPath) } } @@ -316,7 +340,7 @@ func TestGetAuthFromLegacyFile(t *testing.T) { continue } - username, password, err := getAuth(nil, tc.hostname) + username, password, err := config.GetAuthentication(nil, tc.hostname) if err == nil && tc.expectedError != nil { t.Errorf("[%s] got unexpected non error and username=%q, password=%q", tc.name, username, password) continue @@ -387,7 +411,7 @@ func TestGetAuthPreferNewConfig(t *testing.T) { } } - username, password, err := getAuth(nil, "index.docker.io") + username, password, err := config.GetAuthentication(nil, "index.docker.io") if err != nil { t.Fatalf("got unexpected error: %#+v", err) } @@ -401,30 +425,46 @@ func TestGetAuthPreferNewConfig(t *testing.T) { } func TestGetAuthFailsOnBadInput(t *testing.T) { + origXDG := os.Getenv("XDG_RUNTIME_DIR") + tmpDir1, err := ioutil.TempDir("", "test_docker_client_get_auth") + if err != nil { + t.Fatal(err) + } + t.Logf("using temporary XDG_RUNTIME_DIR directory: %q", tmpDir1) + // override homedir + os.Setenv("XDG_RUNTIME_DIR", tmpDir1) + defer func() { + err := os.RemoveAll(tmpDir1) + if err != nil { + t.Logf("failed to cleanup temporary home directory %q: %v", tmpDir1, err) + } + os.Setenv("XDG_RUNTIME_DIR", origXDG) + }() + origHomeDir := homedir.Get() - tmpDir, err := ioutil.TempDir("", "test_docker_client_get_auth") + tmpDir2, err := ioutil.TempDir("", "test_docker_client_get_auth") if err != nil { t.Fatal(err) } - t.Logf("using temporary home directory: %q", tmpDir) + t.Logf("using temporary home directory: %q", tmpDir2) // override homedir - os.Setenv(homedir.Key(), tmpDir) + os.Setenv(homedir.Key(), tmpDir2) defer func() { - err := os.RemoveAll(tmpDir) + err := os.RemoveAll(tmpDir2) if err != nil { - t.Logf("failed to cleanup temporary home directory %q: %v", tmpDir, err) + t.Logf("failed to cleanup temporary home directory %q: %v", tmpDir2, err) } os.Setenv(homedir.Key(), origHomeDir) }() - configDir := filepath.Join(tmpDir, ".docker") + configDir := filepath.Join(tmpDir1, "containers") if err := os.Mkdir(configDir, 0750); err != nil { t.Fatal(err) } - configPath := filepath.Join(configDir, "config.json") + configPath := filepath.Join(configDir, "auth.json") // no config file present - username, password, err := getAuth(nil, "index.docker.io") + username, password, err := config.GetAuthentication(nil, "index.docker.io") if err != nil { t.Fatalf("got unexpected error: %#+v", err) } @@ -435,18 +475,18 @@ func TestGetAuthFailsOnBadInput(t *testing.T) { if err := ioutil.WriteFile(configPath, []byte("Json rocks! Unless it doesn't."), 0640); err != nil { t.Fatalf("failed to write file %q: %v", configPath, err) } - username, password, err = getAuth(nil, "index.docker.io") + username, password, err = config.GetAuthentication(nil, "index.docker.io") if err == nil { t.Fatalf("got unexpected non-error: username=%q, password=%q", username, password) } - if _, ok := err.(*json.SyntaxError); !ok { - t.Fatalf("expected os.PathError, not: %#+v", err) + if _, ok := errors.Cause(err).(*json.SyntaxError); !ok { + t.Fatalf("expected JSON syntax error, not: %#+v", err) } // remove the invalid config file os.RemoveAll(configPath) // no config file present - username, password, err = getAuth(nil, "index.docker.io") + username, password, err = config.GetAuthentication(nil, "index.docker.io") if err != nil { t.Fatalf("got unexpected error: %#+v", err) } @@ -454,16 +494,16 @@ func TestGetAuthFailsOnBadInput(t *testing.T) { t.Fatalf("got unexpected not empty username/password: %q/%q", username, password) } - configPath = filepath.Join(tmpDir, ".dockercfg") + configPath = filepath.Join(tmpDir2, ".dockercfg") if err := ioutil.WriteFile(configPath, []byte("I'm certainly not a json string."), 0640); err != nil { t.Fatalf("failed to write file %q: %v", configPath, err) } - username, password, err = getAuth(nil, "index.docker.io") + username, password, err = config.GetAuthentication(nil, "index.docker.io") if err == nil { t.Fatalf("got unexpected non-error: username=%q, password=%q", username, password) } - if _, ok := err.(*json.SyntaxError); !ok { - t.Fatalf("expected os.PathError, not: %#+v", err) + if _, ok := errors.Cause(err).(*json.SyntaxError); !ok { + t.Fatalf("expected JSON syntax error, not: %#+v", err) } } diff --git a/docker/docker_image_dest.go b/docker/docker_image_dest.go index ee2af92b0b..32d5a18b12 100644 --- a/docker/docker_image_dest.go +++ b/docker/docker_image_dest.go @@ -34,7 +34,7 @@ type dockerImageDestination struct { // newImageDestination creates a new ImageDestination for the specified image reference. func newImageDestination(ctx *types.SystemContext, ref dockerReference) (types.ImageDestination, error) { - c, err := newDockerClient(ctx, ref, true, "pull,push") + c, err := newDockerClientFromRef(ctx, ref, true, "pull,push") if err != nil { return nil, err } diff --git a/docker/docker_image_src.go b/docker/docker_image_src.go index 88e3853a11..232c3cf91b 100644 --- a/docker/docker_image_src.go +++ b/docker/docker_image_src.go @@ -31,7 +31,7 @@ type dockerImageSource struct { // newImageSource creates a new ImageSource for the specified image reference. // The caller must call .Close() on the returned ImageSource. func newImageSource(ctx *types.SystemContext, ref dockerReference) (*dockerImageSource, error) { - c, err := newDockerClient(ctx, ref, false, "pull") + c, err := newDockerClientFromRef(ctx, ref, false, "pull") if err != nil { return nil, err } @@ -298,7 +298,7 @@ func (s *dockerImageSource) getSignaturesFromAPIExtension(ctx context.Context) ( // deleteImage deletes the named image from the registry, if supported. func deleteImage(ctx *types.SystemContext, ref dockerReference) error { - c, err := newDockerClient(ctx, ref, true, "push") + c, err := newDockerClientFromRef(ctx, ref, true, "push") if err != nil { return err } diff --git a/oci/archive/oci_dest.go b/oci/archive/oci_dest.go index ba5a9e8cc1..52e99a43dc 100644 --- a/oci/archive/oci_dest.go +++ b/oci/archive/oci_dest.go @@ -106,7 +106,6 @@ func (d *ociArchiveImageDestination) Commit() error { src := d.tempDirRef.tempDirectory // path to save tarred up file dst := d.ref.resolvedFile - return tarDirectory(src, dst) } diff --git a/pkg/docker/config/config.go b/pkg/docker/config/config.go new file mode 100644 index 0000000000..fd0ae7d84d --- /dev/null +++ b/pkg/docker/config/config.go @@ -0,0 +1,295 @@ +package config + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/containers/image/types" + helperclient "github.com/docker/docker-credential-helpers/client" + "github.com/docker/docker-credential-helpers/credentials" + "github.com/docker/docker/pkg/homedir" + "github.com/pkg/errors" +) + +type dockerAuthConfig struct { + Auth string `json:"auth,omitempty"` +} + +type dockerConfigFile struct { + AuthConfigs map[string]dockerAuthConfig `json:"auths"` + CredHelpers map[string]string `json:"credHelpers,omitempty"` +} + +const ( + defaultPath = "/run/user" + authCfg = "containers" + authCfgFileName = "auth.json" + dockerCfg = ".docker" + dockerCfgFileName = "config.json" + dockerLegacyCfg = ".dockercfg" +) + +var ( + // ErrNotLoggedIn is returned for users not logged into a registry + // that they are trying to logout of + ErrNotLoggedIn = errors.New("not logged in") +) + +// SetAuthentication stores the username and password in the auth.json file +func SetAuthentication(ctx *types.SystemContext, registry, username, password string) error { + return modifyJSON(ctx, func(auths *dockerConfigFile) (bool, error) { + if ch, exists := auths.CredHelpers[registry]; exists { + return false, setAuthToCredHelper(ch, registry, username, password) + } + + creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + newCreds := dockerAuthConfig{Auth: creds} + auths.AuthConfigs[registry] = newCreds + return true, nil + }) +} + +// GetAuthentication returns the registry credentials stored in +// either auth.json file or .docker/config.json +// If an entry is not found empty strings are returned for the username and password +func GetAuthentication(ctx *types.SystemContext, registry string) (string, string, error) { + if ctx != nil && ctx.DockerAuthConfig != nil { + return ctx.DockerAuthConfig.Username, ctx.DockerAuthConfig.Password, nil + } + + dockerLegacyPath := filepath.Join(homedir.Get(), dockerLegacyCfg) + paths := [3]string{getPathToAuth(ctx), filepath.Join(homedir.Get(), dockerCfg, dockerCfgFileName), dockerLegacyPath} + + for _, path := range paths { + legacyFormat := path == dockerLegacyPath + username, password, err := findAuthentication(registry, path, legacyFormat) + if err != nil { + return "", "", err + } + if username != "" && password != "" { + return username, password, nil + } + } + return "", "", nil +} + +// GetUserLoggedIn returns the username logged in to registry from either +// auth.json or XDG_RUNTIME_DIR +// Used to tell the user if someone is logged in to the registry when logging in +func GetUserLoggedIn(ctx *types.SystemContext, registry string) string { + path := getPathToAuth(ctx) + username, _, _ := findAuthentication(registry, path, false) + if username != "" { + return username + } + return "" +} + +// RemoveAuthentication deletes the credentials stored in auth.json +func RemoveAuthentication(ctx *types.SystemContext, registry string) error { + return modifyJSON(ctx, func(auths *dockerConfigFile) (bool, error) { + // First try cred helpers. + if ch, exists := auths.CredHelpers[registry]; exists { + return false, deleteAuthFromCredHelper(ch, registry) + } + + if _, ok := auths.AuthConfigs[registry]; ok { + delete(auths.AuthConfigs, registry) + } else if _, ok := auths.AuthConfigs[normalizeRegistry(registry)]; ok { + delete(auths.AuthConfigs, normalizeRegistry(registry)) + } else { + return false, ErrNotLoggedIn + } + return true, nil + }) +} + +// RemoveAllAuthentication deletes all the credentials stored in auth.json +func RemoveAllAuthentication(ctx *types.SystemContext) error { + return modifyJSON(ctx, func(auths *dockerConfigFile) (bool, error) { + auths.CredHelpers = make(map[string]string) + auths.AuthConfigs = make(map[string]dockerAuthConfig) + return true, nil + }) +} + +// getPath gets the path of the auth.json file +// The path can be overriden by the user if the overwrite-path flag is set +// If the flag is not set and XDG_RUNTIME_DIR is ser, the auth.json file is saved in XDG_RUNTIME_DIR/containers +// Otherwise, the auth.json file is stored in /run/user/UID/containers +func getPathToAuth(ctx *types.SystemContext) string { + if ctx != nil { + if ctx.AuthFilePath != "" { + return ctx.AuthFilePath + } + if ctx.RootForImplicitAbsolutePaths != "" { + return filepath.Join(ctx.RootForImplicitAbsolutePaths, defaultPath, strconv.Itoa(os.Getuid()), authCfg, authCfgFileName) + } + } + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = filepath.Join(defaultPath, strconv.Itoa(os.Getuid())) + } + return filepath.Join(runtimeDir, authCfg, authCfgFileName) +} + +// readJSONFile unmarshals the authentications stored in the auth.json file and returns it +// or returns an empty dockerConfigFile data structure if auth.json does not exist +// if the file exists and is empty, readJSONFile returns an error +func readJSONFile(path string, legacyFormat bool) (dockerConfigFile, error) { + var auths dockerConfigFile + + raw, err := ioutil.ReadFile(path) + if os.IsNotExist(err) { + auths.AuthConfigs = map[string]dockerAuthConfig{} + return auths, nil + } + + if legacyFormat { + if err = json.Unmarshal(raw, &auths.AuthConfigs); err != nil { + return dockerConfigFile{}, errors.Wrapf(err, "error unmarshaling JSON at %q", path) + } + return auths, nil + } + + if err = json.Unmarshal(raw, &auths); err != nil { + return dockerConfigFile{}, errors.Wrapf(err, "error unmarshaling JSON at %q", path) + } + + return auths, nil +} + +// modifyJSON writes to auth.json if the dockerConfigFile has been updated +func modifyJSON(ctx *types.SystemContext, editor func(auths *dockerConfigFile) (bool, error)) error { + path := getPathToAuth(ctx) + dir := filepath.Dir(path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err = os.Mkdir(dir, 0700); err != nil { + return errors.Wrapf(err, "error creating directory %q", dir) + } + } + + auths, err := readJSONFile(path, false) + if err != nil { + return errors.Wrapf(err, "error reading JSON file %q", path) + } + + updated, err := editor(&auths) + if err != nil { + return errors.Wrapf(err, "error updating %q", path) + } + if updated { + newData, err := json.MarshalIndent(auths, "", "\t") + if err != nil { + return errors.Wrapf(err, "error marshaling JSON %q", path) + } + + if err = ioutil.WriteFile(path, newData, 0755); err != nil { + return errors.Wrapf(err, "error writing to file %q", path) + } + } + + return nil +} + +func getAuthFromCredHelper(credHelper, registry string) (string, string, error) { + helperName := fmt.Sprintf("docker-credential-%s", credHelper) + p := helperclient.NewShellProgramFunc(helperName) + creds, err := helperclient.Get(p, registry) + if err != nil { + return "", "", err + } + return creds.Username, creds.Secret, nil +} + +func setAuthToCredHelper(credHelper, registry, username, password string) error { + helperName := fmt.Sprintf("docker-credential-%s", credHelper) + p := helperclient.NewShellProgramFunc(helperName) + creds := &credentials.Credentials{ + ServerURL: registry, + Username: username, + Secret: password, + } + return helperclient.Store(p, creds) +} + +func deleteAuthFromCredHelper(credHelper, registry string) error { + helperName := fmt.Sprintf("docker-credential-%s", credHelper) + p := helperclient.NewShellProgramFunc(helperName) + return helperclient.Erase(p, registry) +} + +// findAuthentication looks for auth of registry in path +func findAuthentication(registry, path string, legacyFormat bool) (string, string, error) { + auths, err := readJSONFile(path, legacyFormat) + if err != nil { + return "", "", errors.Wrapf(err, "error reading JSON file %q", path) + } + + // First try cred helpers. They should always be normalized. + if ch, exists := auths.CredHelpers[registry]; exists { + return getAuthFromCredHelper(ch, registry) + } + + // I'm feeling lucky + if val, exists := auths.AuthConfigs[registry]; exists { + return decodeDockerAuth(val.Auth) + } + + // bad luck; let's normalize the entries first + registry = normalizeRegistry(registry) + normalizedAuths := map[string]dockerAuthConfig{} + for k, v := range auths.AuthConfigs { + normalizedAuths[normalizeRegistry(k)] = v + } + if val, exists := normalizedAuths[registry]; exists { + return decodeDockerAuth(val.Auth) + } + return "", "", nil +} + +func decodeDockerAuth(s string) (string, string, error) { + decoded, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "", "", err + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + // if it's invalid just skip, as docker does + return "", "", nil + } + user := parts[0] + password := strings.Trim(parts[1], "\x00") + return user, password, nil +} + +// convertToHostname converts a registry url which has http|https prepended +// to just an hostname. +// Copied from github.com/docker/docker/registry/auth.go +func convertToHostname(url string) string { + stripped := url + if strings.HasPrefix(url, "http://") { + stripped = strings.TrimPrefix(url, "http://") + } else if strings.HasPrefix(url, "https://") { + stripped = strings.TrimPrefix(url, "https://") + } + + nameParts := strings.SplitN(stripped, "/", 2) + + return nameParts[0] +} + +func normalizeRegistry(registry string) string { + normalized := convertToHostname(registry) + switch normalized { + case "registry-1.docker.io", "docker.io": + return "index.docker.io" + } + return normalized +} diff --git a/types/types.go b/types/types.go index e955c33806..4ede907bcd 100644 --- a/types/types.go +++ b/types/types.go @@ -304,6 +304,8 @@ type SystemContext struct { RegistriesDirPath string // Path to the system-wide registries configuration file SystemRegistriesConfPath string + // If not "", overrides the default path for the authentication file + AuthFilePath string // === OCI.Transport overrides === // If not "", a directory containing a CA certificate (ending with ".crt"),