blob: c42f6981c2ffd0e31540cfbeab7a6b264558630a [file] [log] [blame]
// Licensed to the Apache Software Foundation (ASF) under one or more
// contributor license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright ownership.
// The ASF licenses this file to You under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package creds
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
)
import (
dockerConfig "github.com/containers/image/v5/pkg/docker/config"
containersTypes "github.com/containers/image/v5/types"
"github.com/docker/docker-credential-helpers/client"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
)
import (
"github.com/apache/dubbo-kubernetes/app/dubboctl/internal/docker"
)
type CredentialsCallback func(registry string) (docker.Credentials, error)
var ErrUnauthorized = errors.New("bad credentials")
var ErrCredentialsNotFound = errors.New("credentials not found")
// VerifyCredentialsCallback checks if credentials are authorized for image push.
// If credentials are incorrect this callback shall return ErrUnauthorized.
type VerifyCredentialsCallback func(ctx context.Context, image string, credentials docker.Credentials) error
type keyChain struct {
user string
pwd string
}
func (k keyChain) Resolve(resource authn.Resource) (authn.Authenticator, error) {
return &authn.Basic{
Username: k.user,
Password: k.pwd,
}, nil
}
// CheckAuth verifies that credentials can be used for image push
func CheckAuth(ctx context.Context, image string, credentials docker.Credentials, trans http.RoundTripper) error {
ref, err := name.ParseReference(image)
if err != nil {
return fmt.Errorf("cannot parse image reference: %w", err)
}
kc := keyChain{
user: credentials.Username,
pwd: credentials.Password,
}
err = remote.CheckPushPermission(ref, kc, trans)
if err != nil {
var transportErr *transport.Error
if errors.As(err, &transportErr) && transportErr.StatusCode == 401 {
return ErrUnauthorized
}
return err
}
return nil
}
type ChooseCredentialHelperCallback func(available []string) (string, error)
type credentialsProvider struct {
promptForCredentials CredentialsCallback
verifyCredentials VerifyCredentialsCallback
promptForCredentialStore ChooseCredentialHelperCallback
credentialLoaders []CredentialsCallback
authFilePath string
transport http.RoundTripper
}
type Opt func(opts *credentialsProvider)
// WithPromptForCredentials sets custom callback that is supposed to
// interactively ask for credentials in case the credentials cannot be found in configuration files.
// The callback may be called multiple times in case incorrect credentials were returned before.
func WithPromptForCredentials(cbk CredentialsCallback) Opt {
return func(opts *credentialsProvider) {
opts.promptForCredentials = cbk
}
}
// WithVerifyCredentials sets custom callback for credentials validation.
func WithVerifyCredentials(cbk VerifyCredentialsCallback) Opt {
return func(opts *credentialsProvider) {
opts.verifyCredentials = cbk
}
}
// WithPromptForCredentialStore sets custom callback that is supposed to
// interactively ask user which credentials store/helper is used to store credentials obtained
// from user.
func WithPromptForCredentialStore(cbk ChooseCredentialHelperCallback) Opt {
return func(opts *credentialsProvider) {
opts.promptForCredentialStore = cbk
}
}
func WithTransport(transport http.RoundTripper) Opt {
return func(opts *credentialsProvider) {
opts.transport = transport
}
}
// WithAdditionalCredentialLoaders adds custom callbacks for credential retrieval.
// The callbacks shall return ErrCredentialsNotFound if the credentials are not found.
// The callbacks are supposed to be non-interactive as opposed to WithPromptForCredentials.
//
// This might be useful when credentials are shared with some other service.
//
// Example: OpenShift builtin registry shares credentials with the cluster (k8s) credentials.
func WithAdditionalCredentialLoaders(loaders ...CredentialsCallback) Opt {
return func(opts *credentialsProvider) {
opts.credentialLoaders = append(opts.credentialLoaders, loaders...)
}
}
// NewCredentialsProvider returns new CredentialsProvider that tries to get credentials from docker/func config files.
//
// In case getting credentials from the config files fails
// the caller provided callback (see WithPromptForCredentials) will be invoked to obtain credentials.
// The callback may be called multiple times in case the returned credentials
// are not correct (see WithVerifyCredentials).
//
// When the callback succeeds the credentials will be saved by using helper defined in the func config.
// If the helper is not defined in the config file
// it may be picked by provided callback (see WithPromptForCredentialStore).
// The picked value will be saved in the func config.
//
// To verify that credentials are correct custom callback can be used (see WithVerifyCredentials).
func NewCredentialsProvider(configPath string, opts ...Opt) docker.CredentialsProvider {
var c credentialsProvider
for _, o := range opts {
o(&c)
}
if c.transport == nil {
c.transport = http.DefaultTransport
}
if c.verifyCredentials == nil {
c.verifyCredentials = func(ctx context.Context, registry string, credentials docker.Credentials) error {
return CheckAuth(ctx, registry, credentials, c.transport)
}
}
if c.promptForCredentialStore == nil {
c.promptForCredentialStore = func(available []string) (string, error) {
return "", nil
}
}
c.authFilePath = filepath.Join(configPath, "auth.json")
sys := &containersTypes.SystemContext{
AuthFilePath: c.authFilePath,
}
home, err := os.UserHomeDir()
if err != nil {
panic(err)
}
dockerConfigPath := filepath.Join(home, ".docker", "config.json")
defaultCredentialLoaders := []CredentialsCallback{
func(registry string) (docker.Credentials, error) {
return getCredentialsByCredentialHelper(c.authFilePath, registry)
},
func(registry string) (docker.Credentials, error) {
return getCredentialsByCredentialHelper(dockerConfigPath, registry)
},
func(registry string) (docker.Credentials, error) {
creds, err := dockerConfig.GetCredentials(sys, registry)
if err != nil {
return docker.Credentials{}, err
}
return docker.Credentials{
Username: creds.Username,
Password: creds.Password,
}, nil
},
func(registry string) (docker.Credentials, error) { // empty credentials provider for unsecured registries
return docker.Credentials{}, nil
},
}
c.credentialLoaders = append(c.credentialLoaders, defaultCredentialLoaders...)
return c.getCredentials
}
func (c *credentialsProvider) getCredentials(ctx context.Context, image string) (docker.Credentials, error) {
var err error
result := docker.Credentials{}
ref, err := name.ParseReference(image)
if err != nil {
return docker.Credentials{}, fmt.Errorf("cannot parse the image reference: %w", err)
}
registry := ref.Context().RegistryStr()
for _, load := range c.credentialLoaders {
result, err = load(registry)
if err != nil {
if errors.Is(err, ErrCredentialsNotFound) {
continue
}
return docker.Credentials{}, err
}
err = c.verifyCredentials(ctx, image, result)
if err == nil {
return result, nil
} else {
if !errors.Is(err, ErrUnauthorized) {
return docker.Credentials{}, err
}
}
}
if c.promptForCredentials == nil {
return docker.Credentials{}, ErrCredentialsNotFound
}
for {
result, err = c.promptForCredentials(registry)
if err != nil {
return docker.Credentials{}, err
}
err = c.verifyCredentials(ctx, image, result)
if err == nil {
err = setCredentialsByCredentialHelper(c.authFilePath, registry, result.Username, result.Password)
if err != nil {
// This shouldn't be fatal error.
if strings.Contains(err.Error(), "not implemented") {
fmt.Fprintf(os.Stderr, "the cred-helper does not support write operation (consider changing the cred-helper it in auth.json)\n")
return docker.Credentials{}, nil
}
if !errors.Is(err, errNoCredentialHelperConfigured) {
return docker.Credentials{}, err
}
helpers := listCredentialHelpers()
helper, err := c.promptForCredentialStore(helpers)
if err != nil {
return docker.Credentials{}, err
}
helper = strings.TrimPrefix(helper, "docker-credential-")
err = setCredentialHelperToConfig(c.authFilePath, helper)
if err != nil {
return docker.Credentials{}, fmt.Errorf("faild to set the helper to the config: %w", err)
}
err = setCredentialsByCredentialHelper(c.authFilePath, registry, result.Username, result.Password)
if err != nil {
// This shouldn't be fatal error.
if strings.Contains(err.Error(), "not implemented") {
fmt.Fprintf(os.Stderr, "the cred-helper does not support write operation (consider changing the cred-helper it in auth.json)\n")
return docker.Credentials{}, nil
}
if !errors.Is(err, errNoCredentialHelperConfigured) {
return docker.Credentials{}, err
}
}
}
return result, nil
} else {
if errors.Is(err, ErrUnauthorized) {
continue
}
return docker.Credentials{}, err
}
}
}
var errNoCredentialHelperConfigured = errors.New("no credential helper configure")
func getCredentialHelperFromConfig(confFilePath string) (string, error) {
data, err := os.ReadFile(confFilePath)
if err != nil {
return "", err
}
conf := struct {
Store string `json:"credsStore"`
}{}
err = json.Unmarshal(data, &conf)
if err != nil {
return "", err
}
return conf.Store, nil
}
func setCredentialHelperToConfig(confFilePath, helper string) error {
var err error
configData := make(map[string]interface{})
if data, err := os.ReadFile(confFilePath); err == nil {
err = json.Unmarshal(data, &configData)
if err != nil {
return err
}
}
configData["credsStore"] = helper
data, err := json.MarshalIndent(&configData, "", " ")
if err != nil {
return err
}
err = os.WriteFile(confFilePath, data, 0o600)
if err != nil {
return err
}
return nil
}
func getCredentialsByCredentialHelper(confFilePath, registry string) (docker.Credentials, error) {
result := docker.Credentials{}
helper, err := getCredentialHelperFromConfig(confFilePath)
if err != nil && !os.IsNotExist(err) {
return result, fmt.Errorf("failed to get helper from config: %w", err)
}
if helper == "" {
return result, ErrCredentialsNotFound
}
helperName := fmt.Sprintf("docker-credential-%s", helper)
p := client.NewShellProgramFunc(helperName)
credentialsMap, err := client.List(p)
if err != nil {
return result, fmt.Errorf("failed to list credentials: %w", err)
}
for serverUrl := range credentialsMap {
if RegistryEquals(serverUrl, registry) {
creds, err := client.Get(p, serverUrl)
if err != nil {
return result, fmt.Errorf("failed to get credentials: %w", err)
}
result.Username = creds.Username
result.Password = creds.Secret
return result, nil
}
}
return result, fmt.Errorf("failed to get credentials from helper specified in ~/.docker/config.json: %w", ErrCredentialsNotFound)
}
func setCredentialsByCredentialHelper(confFilePath, registry, username, secret string) error {
helper, err := getCredentialHelperFromConfig(confFilePath)
if helper == "" || os.IsNotExist(err) {
return errNoCredentialHelperConfigured
}
if err != nil {
return fmt.Errorf("failed to get helper from config: %w", err)
}
helperName := fmt.Sprintf("docker-credential-%s", helper)
p := client.NewShellProgramFunc(helperName)
return client.Store(p, &credentials.Credentials{ServerURL: registry, Username: username, Secret: secret})
}
func listCredentialHelpers() []string {
path := os.Getenv("PATH")
paths := strings.Split(path, string(os.PathListSeparator))
helpers := make(map[string]bool)
for _, p := range paths {
fss, err := os.ReadDir(p)
if err != nil {
continue
}
for _, fi := range fss {
if fi.IsDir() {
continue
}
if !strings.HasPrefix(fi.Name(), "docker-credential-") {
continue
}
if runtime.GOOS == "windows" {
ext := filepath.Ext(fi.Name())
if ext != ".exe" && ext != ".bat" {
continue
}
}
helpers[fi.Name()] = true
}
}
result := make([]string, 0, len(helpers))
for h := range helpers {
result = append(result, h)
}
return result
}
func hostPort(registry string) (host string, port string) {
if !strings.Contains(registry, "://") {
h, p, err := net.SplitHostPort(registry)
if err == nil {
host, port = h, p
return
}
registry = "https://" + registry
}
u, err := url.Parse(registry)
if err != nil {
panic(err)
}
host = u.Hostname()
port = u.Port()
return
}
// RegistryEquals checks whether registry matches in host and port
// with exception where empty port matches standard ports (80,443)
func RegistryEquals(regA, regB string) bool {
h1, p1 := hostPort(regA)
h2, p2 := hostPort(regB)
isStdPort := func(p string) bool { return p == "443" || p == "80" }
portEq := p1 == p2 ||
(p1 == "" && isStdPort(p2)) ||
(isStdPort(p1) && p2 == "")
if h1 == h2 && portEq {
return true
}
if strings.HasSuffix(h1, "docker.io") &&
strings.HasSuffix(h2, "docker.io") {
return true
}
return false
}