mirror of https://github.com/knative/func.git
498 lines
14 KiB
Go
498 lines
14 KiB
Go
package creds
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
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"
|
|
|
|
"knative.dev/func/pkg/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
|
|
}
|
|
}
|
|
|
|
// default credential loaders map -- load only those that should be there.
|
|
var defaultCredentialLoaders = []CredentialsCallback{}
|
|
|
|
c.authFilePath = filepath.Join(configPath, "auth.json")
|
|
sys := &containersTypes.SystemContext{
|
|
AuthFilePath: c.authFilePath,
|
|
}
|
|
|
|
if _, err := os.Stat(c.authFilePath); err == nil {
|
|
defaultCredentialLoaders = append(defaultCredentialLoaders,
|
|
func(registry string) (docker.Credentials, error) {
|
|
return getCredentialsByCredentialHelper(c.authFilePath, registry)
|
|
})
|
|
}
|
|
|
|
// add only if home dir is defined -- for .docker/config.json creds
|
|
home, err := os.UserHomeDir()
|
|
if err == nil {
|
|
dockerConfigPath := filepath.Join(home, ".docker", "config.json")
|
|
defaultCredentialLoaders = append(defaultCredentialLoaders,
|
|
func(registry string) (docker.Credentials, error) {
|
|
return getCredentialsByCredentialHelper(dockerConfigPath, registry)
|
|
})
|
|
}
|
|
defaultCredentialLoaders = append(defaultCredentialLoaders,
|
|
func(registry string) (docker.Credentials, error) {
|
|
creds, err := dockerConfig.GetCredentials(sys, registry)
|
|
if err != nil {
|
|
return docker.Credentials{}, err
|
|
}
|
|
if creds.Username == "" || creds.Password == "" {
|
|
return docker.Credentials{}, ErrCredentialsNotFound
|
|
}
|
|
return docker.Credentials{
|
|
Username: creds.Username,
|
|
Password: creds.Password,
|
|
}, nil
|
|
})
|
|
defaultCredentialLoaders = append(defaultCredentialLoaders,
|
|
func(registry string) (docker.Credentials, error) {
|
|
// Fallback onto default docker config locations
|
|
emptySys := &containersTypes.SystemContext{}
|
|
creds, err := dockerConfig.GetCredentials(emptySys, registry)
|
|
if err != nil {
|
|
return docker.Credentials{}, err
|
|
}
|
|
return docker.Credentials{
|
|
Username: creds.Username,
|
|
Password: creds.Password,
|
|
}, nil
|
|
})
|
|
defaultCredentialLoaders = append(defaultCredentialLoaders,
|
|
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
|
|
}
|
|
// create config path if doesnt exist
|
|
err = os.MkdirAll(filepath.Dir(confFilePath), 0755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = os.WriteFile(confFilePath, data, 0600)
|
|
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
|
|
}
|