...

Source file src/edge-infra.dev/pkg/f8n/warehouse/k8s/kauth/keychain.go

Documentation: edge-infra.dev/pkg/f8n/warehouse/k8s/kauth

     1  // Package kauthn implements functionality for producing OCI registry keychains
     2  // from K8s service accounts.
     3  //
     4  // Originally forked from https://github.com/google/go-containerregistry/tree/main/pkg/authn/kubernetes
     5  // and updated to use controller-runtime/pkg/client for easier integration with
     6  // our controllers.
     7  package kauth
     8  
     9  import (
    10  	"context"
    11  	"encoding/json"
    12  	"fmt"
    13  	"net"
    14  	"net/url"
    15  	"path/filepath"
    16  	"sort"
    17  	"strings"
    18  
    19  	"github.com/google/go-containerregistry/pkg/authn"
    20  	"github.com/google/go-containerregistry/pkg/logs"
    21  	corev1 "k8s.io/api/core/v1"
    22  	k8serrors "k8s.io/apimachinery/pkg/api/errors"
    23  	"k8s.io/client-go/rest"
    24  	"sigs.k8s.io/controller-runtime/pkg/client"
    25  )
    26  
    27  const (
    28  	// NoServiceAccount is a constant that can be passed via ServiceAccountName
    29  	// to tell the keychain that looking up the service account is unnecessary.
    30  	// This value cannot collide with an actual service account name because
    31  	// service accounts do not allow spaces.
    32  	NoServiceAccount = "no service account"
    33  )
    34  
    35  // Options holds configuration data for guiding credential resolution.
    36  type Options struct {
    37  	// Namespace holds the namespace inside of which we are resolving service
    38  	// account and pull secret references to access the image.
    39  	// If empty, "default" is assumed.
    40  	Namespace string
    41  
    42  	// ServiceAccountName holds the serviceaccount (within Namespace) as which a
    43  	// Pod might access the image.  Service accounts may have image pull secrets
    44  	// attached, so we lookup the service account to complete the keychain.
    45  	// If empty, "default" is assumed.  To avoid a service account lookup, pass
    46  	// NoServiceAccount explicitly.
    47  	ServiceAccountName string
    48  
    49  	// ImagePullSecrets holds the names of the Kubernetes secrets (scoped to
    50  	// Namespace) containing credential data to use for the image pull.
    51  	ImagePullSecrets []string
    52  
    53  	// UseMountSecrets determines whether or not mount secrets in the ServiceAccount
    54  	// should be considered. Mount secrets are those listed under the `.secrets`
    55  	// attribute of the ServiceAccount resource. Ignored if ServiceAccountName is set
    56  	// to NoServiceAccount.
    57  	UseMountSecrets bool
    58  }
    59  
    60  // New returns a new authn.Keychain suitable for resolving image references as
    61  // scoped by the provided Options.
    62  func New(ctx context.Context, c client.Client, opt Options) (authn.Keychain, error) {
    63  	if opt.Namespace == "" {
    64  		opt.Namespace = "default"
    65  	}
    66  	if opt.ServiceAccountName == "" {
    67  		opt.ServiceAccountName = "default"
    68  	}
    69  
    70  	// Implement a Kubernetes-style authentication keychain.
    71  	// This needs to support roughly the following kinds of authentication:
    72  	//  1) The explicit authentication from imagePullSecrets on Pod
    73  	//  2) The semi-implicit authentication where imagePullSecrets are on the
    74  	//    Pod's service account.
    75  
    76  	// First, fetch all of the explicitly declared pull secrets
    77  	var pullSecrets []corev1.Secret
    78  	for _, name := range opt.ImagePullSecrets {
    79  		ps := &corev1.Secret{}
    80  		err := c.Get(ctx, client.ObjectKey{
    81  			Name: name, Namespace: opt.Namespace,
    82  		}, ps)
    83  		if k8serrors.IsNotFound(err) {
    84  			logs.Warn.Printf("secret %s/%s not found; ignoring", opt.Namespace, name)
    85  			continue
    86  		} else if err != nil {
    87  			return nil, err
    88  		}
    89  		pullSecrets = append(pullSecrets, *ps)
    90  	}
    91  
    92  	// Second, fetch all of the pull secrets attached to our service account,
    93  	// unless the user has explicitly specified that no service account lookup
    94  	// is desired.
    95  	//nolint:nestif
    96  	if opt.ServiceAccountName != NoServiceAccount {
    97  		sa := &corev1.ServiceAccount{}
    98  		err := c.Get(ctx, client.ObjectKey{
    99  			Name: opt.ServiceAccountName, Namespace: opt.Namespace,
   100  		}, sa)
   101  		switch {
   102  		case k8serrors.IsNotFound(err):
   103  			logs.Warn.Printf("serviceaccount %s/%s not found; ignoring", opt.Namespace, opt.ServiceAccountName)
   104  		case err != nil:
   105  			return nil, err
   106  		case err == nil:
   107  			for _, localObj := range sa.ImagePullSecrets {
   108  				ps := &corev1.Secret{}
   109  				err := c.Get(ctx, client.ObjectKey{
   110  					Name: localObj.Name, Namespace: opt.Namespace,
   111  				}, ps)
   112  
   113  				if k8serrors.IsNotFound(err) {
   114  					logs.Warn.Printf("secret %s/%s not found; ignoring", opt.Namespace, localObj.Name)
   115  					continue
   116  				} else if err != nil {
   117  					return nil, err
   118  				}
   119  				pullSecrets = append(pullSecrets, *ps)
   120  			}
   121  
   122  			if opt.UseMountSecrets {
   123  				for _, obj := range sa.Secrets {
   124  					s := &corev1.Secret{}
   125  					err := c.Get(ctx, client.ObjectKey{
   126  						Name: obj.Name, Namespace: opt.Namespace,
   127  					}, s)
   128  					if k8serrors.IsNotFound(err) {
   129  						logs.Warn.Printf("secret %s/%s not found; ignoring", opt.Namespace, obj.Name)
   130  						continue
   131  					} else if err != nil {
   132  						return nil, err
   133  					}
   134  					pullSecrets = append(pullSecrets, *s)
   135  				}
   136  			}
   137  		}
   138  	}
   139  
   140  	return NewFromPullSecrets(pullSecrets)
   141  }
   142  
   143  // NewInCluster returns a new authn.Keychain suitable for resolving image references as
   144  // scoped by the provided Options, constructing a kubernetes.Interface based on in-cluster
   145  // authentication.
   146  func NewInCluster(ctx context.Context, opt Options) (authn.Keychain, error) {
   147  	clusterConfig, err := rest.InClusterConfig()
   148  	if err != nil {
   149  		return nil, err
   150  	}
   151  	c, err := client.New(clusterConfig, client.Options{})
   152  	if err != nil {
   153  		return nil, err
   154  	}
   155  	return New(ctx, c, opt)
   156  }
   157  
   158  type dockerConfigJSON struct {
   159  	Auths map[string]authn.AuthConfig
   160  }
   161  
   162  // NewFromPullSecrets returns a new authn.Keychain suitable for resolving image references as
   163  // scoped by the pull secrets.
   164  func NewFromPullSecrets(secrets []corev1.Secret) (authn.Keychain, error) {
   165  	keyring := &keyring{
   166  		index: make([]string, 0),
   167  		creds: make(map[string][]authn.AuthConfig),
   168  	}
   169  
   170  	var cfg dockerConfigJSON
   171  
   172  	// From: https://github.com/kubernetes/kubernetes/blob/0dcafb1f37ee522be3c045753623138e5b907001/pkg/credentialprovider/keyring.go
   173  	for _, secret := range secrets {
   174  		if b, exists := secret.Data[corev1.DockerConfigJsonKey]; secret.Type == corev1.SecretTypeDockerConfigJson && exists && len(b) > 0 {
   175  			if err := json.Unmarshal(b, &cfg); err != nil {
   176  				return nil, err
   177  			}
   178  		}
   179  		if b, exists := secret.Data[corev1.DockerConfigKey]; secret.Type == corev1.SecretTypeDockercfg && exists && len(b) > 0 {
   180  			if err := json.Unmarshal(b, &cfg.Auths); err != nil {
   181  				return nil, err
   182  			}
   183  		}
   184  
   185  		for registry, v := range cfg.Auths {
   186  			value := registry
   187  			if !strings.HasPrefix(value, "https://") && !strings.HasPrefix(value, "http://") {
   188  				value = "https://" + value
   189  			}
   190  			parsed, err := url.Parse(value)
   191  			if err != nil {
   192  				return nil, fmt.Errorf("Entry %q in dockercfg invalid (%w)", value, err)
   193  			}
   194  
   195  			// The docker client allows exact matches:
   196  			//    foo.bar.com/namespace
   197  			// Or hostname matches:
   198  			//    foo.bar.com
   199  			// It also considers /v2/  and /v1/ equivalent to the hostname
   200  			// See ResolveAuthConfig in docker/registry/auth.go.
   201  			effectivePath := parsed.Path
   202  			if strings.HasPrefix(effectivePath, "/v2/") || strings.HasPrefix(effectivePath, "/v1/") {
   203  				effectivePath = effectivePath[3:]
   204  			}
   205  			var key string
   206  			if (len(effectivePath) > 0) && (effectivePath != "/") {
   207  				key = parsed.Host + effectivePath
   208  			} else {
   209  				key = parsed.Host
   210  			}
   211  
   212  			if _, ok := keyring.creds[key]; !ok {
   213  				keyring.index = append(keyring.index, key)
   214  			}
   215  
   216  			keyring.creds[key] = append(keyring.creds[key], v)
   217  		}
   218  
   219  		// We reverse sort in to give more specific (aka longer) keys priority
   220  		// when matching for creds
   221  		sort.Sort(sort.Reverse(sort.StringSlice(keyring.index)))
   222  	}
   223  	return keyring, nil
   224  }
   225  
   226  type keyring struct {
   227  	index []string
   228  	creds map[string][]authn.AuthConfig
   229  }
   230  
   231  func (keyring *keyring) Resolve(target authn.Resource) (authn.Authenticator, error) {
   232  	image := target.String()
   233  	auths := []authn.AuthConfig{}
   234  
   235  	for _, k := range keyring.index {
   236  		// both k and image are schemeless URLs because even though schemes are allowed
   237  		// in the credential configurations, we remove them when constructing the keyring
   238  		if matched, _ := urlsMatchStr(k, image); matched {
   239  			auths = append(auths, keyring.creds[k]...)
   240  		}
   241  	}
   242  
   243  	if len(auths) == 0 {
   244  		return authn.Anonymous, nil
   245  	}
   246  
   247  	return toAuthenticator(auths)
   248  }
   249  
   250  // urlsMatchStr is wrapper for URLsMatch, operating on strings instead of URLs.
   251  func urlsMatchStr(glob string, target string) (bool, error) {
   252  	globURL, err := parseSchemelessURL(glob)
   253  	if err != nil {
   254  		return false, err
   255  	}
   256  	targetURL, err := parseSchemelessURL(target)
   257  	if err != nil {
   258  		return false, err
   259  	}
   260  	return urlsMatch(globURL, targetURL)
   261  }
   262  
   263  // parseSchemelessURL parses a schemeless url and returns a url.URL
   264  // url.Parse require a scheme, but ours don't have schemes.  Adding a
   265  // scheme to make url.Parse happy, then clear out the resulting scheme.
   266  func parseSchemelessURL(schemelessURL string) (*url.URL, error) {
   267  	parsed, err := url.Parse("https://" + schemelessURL)
   268  	if err != nil {
   269  		return nil, err
   270  	}
   271  	// clear out the resulting scheme
   272  	parsed.Scheme = ""
   273  	return parsed, nil
   274  }
   275  
   276  // splitURL splits the host name into parts, as well as the port
   277  func splitURL(url *url.URL) (parts []string, port string) {
   278  	host, port, err := net.SplitHostPort(url.Host)
   279  	if err != nil {
   280  		// could not parse port
   281  		host, port = url.Host, ""
   282  	}
   283  	return strings.Split(host, "."), port
   284  }
   285  
   286  // urlsMatch checks whether the given target url matches the glob url, which may have
   287  // glob wild cards in the host name.
   288  //
   289  // Examples:
   290  //
   291  //	globURL=*.docker.io, targetURL=blah.docker.io => match
   292  //	globURL=*.docker.io, targetURL=not.right.io   => no match
   293  //
   294  // Note that we don't support wildcards in ports and paths yet.
   295  func urlsMatch(globURL *url.URL, targetURL *url.URL) (bool, error) {
   296  	globURLParts, globPort := splitURL(globURL)
   297  	targetURLParts, targetPort := splitURL(targetURL)
   298  	if globPort != targetPort {
   299  		// port doesn't match
   300  		return false, nil
   301  	}
   302  	if len(globURLParts) != len(targetURLParts) {
   303  		// host name does not have the same number of parts
   304  		return false, nil
   305  	}
   306  	if !strings.HasPrefix(targetURL.Path, globURL.Path) {
   307  		// the path of the credential must be a prefix
   308  		return false, nil
   309  	}
   310  	for k, globURLPart := range globURLParts {
   311  		targetURLPart := targetURLParts[k]
   312  		matched, err := filepath.Match(globURLPart, targetURLPart)
   313  		if err != nil {
   314  			return false, err
   315  		}
   316  		if !matched {
   317  			// glob mismatch for some part
   318  			return false, nil
   319  		}
   320  	}
   321  	// everything matches
   322  	return true, nil
   323  }
   324  
   325  func toAuthenticator(configs []authn.AuthConfig) (authn.Authenticator, error) {
   326  	cfg := configs[0]
   327  
   328  	if cfg.Auth != "" {
   329  		cfg.Auth = ""
   330  	}
   331  
   332  	return authn.FromConfig(cfg), nil
   333  }
   334  

View as plain text