karmada/pkg/karmadactl/util/bootstraptoken/bootstraptoken.go

357 lines
13 KiB
Go

package bootstraptoken
import (
"context"
"crypto/x509"
"fmt"
"sort"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
kubeclient "k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
clientcertutil "k8s.io/client-go/util/cert"
bootstrapapi "k8s.io/cluster-bootstrap/token/api"
bootstraputil "k8s.io/cluster-bootstrap/token/util"
bootstrapsecretutil "k8s.io/cluster-bootstrap/util/secrets"
"k8s.io/klog/v2"
cmdutil "github.com/karmada-io/karmada/pkg/karmadactl/util"
"github.com/karmada-io/karmada/pkg/util/lifted/pubkeypin"
)
const (
// When a token is matched with 'BootstrapTokenPattern', the size of validated substrings returned by
// regexp functions which contains 'Submatch' in their names will be 3.
// Submatch 0 is the match of the entire expression, submatch 1 is
// the match of the first parenthesized subexpression, and so on.
// e.g.:
// result := bootstraputil.BootstrapTokenRegexp.FindStringSubmatch("abcdef.1234567890123456")
// result == []string{"abcdef.1234567890123456","abcdef","1234567890123456"}
// len(result) == 3
validatedSubstringsSize = 3
// DefaultTokenDuration specifies the default amount of time that a bootstrap token will be valid
// Default behaviour is 24 hours
DefaultTokenDuration = 24 * time.Hour
)
var (
// DefaultUsages is the default usages of bootstrap token
DefaultUsages = bootstrapapi.KnownTokenUsages
// DefaultGroups is the default groups of bootstrap token
DefaultGroups = []string{"system:bootstrappers:karmada:default-cluster-token"}
)
// BootstrapToken describes one bootstrap token, stored as a Secret in the cluster
type BootstrapToken struct {
// Token is used for establishing bidirectional trust between clusters and karmada-control-plane.
// Used for joining clusters to the karmada-control-plane.
Token *Token
// Description sets a human-friendly message why this token exists and what it's used
// for, so other administrators can know its purpose.
// +optional
Description string
// TTL defines the time to live for this token. Defaults to 24h.
// Expires and TTL are mutually exclusive.
// +optional
TTL *metav1.Duration
// Expires specifies the timestamp when this token expires. Defaults to being set
// dynamically at runtime based on the TTL. Expires and TTL are mutually exclusive.
// +optional
Expires *metav1.Time
// Usages describes the ways in which this token can be used. Can by default be used
// for establishing bidirectional trust, but that can be changed here.
// +optional
Usages []string
// Groups specifies the extra groups that this token will authenticate as when/if
// used for authentication
// +optional
Groups []string
}
// Token is a token of the format abcdef.abcdef0123456789 that is used
// for both validation of the practically of the API server from a joining cluster's point
// of view and as an authentication method for the cluster in the bootstrap phase of
// "karmadactl join". This token is and should be short-lived
type Token struct {
ID string
Secret string
}
// GenerateRegisterCommand generate register command that will be printed
func GenerateRegisterCommand(kubeConfig, parentCommand, token string, karmadaContext string) (string, error) {
klog.V(1).Info("Print register command")
// load the kubeconfig file to get the CA certificate and endpoint
config, err := clientcmd.LoadFromFile(kubeConfig)
if err != nil {
return "", fmt.Errorf("failed to load kubeconfig, err: %w", err)
}
// load the cluster config with the given karmada-context
clusterConfig := GetClusterFromKubeConfig(config, karmadaContext)
if clusterConfig == nil {
return "", fmt.Errorf("failed to get default cluster config")
}
// load CA certificates from the kubeconfig (either from PEM data or by file path)
var caCerts []*x509.Certificate
if clusterConfig.CertificateAuthorityData != nil {
caCerts, err = clientcertutil.ParseCertsPEM(clusterConfig.CertificateAuthorityData)
if err != nil {
return "", fmt.Errorf("failed to parse CA certificate from kubeconfig, err: %w", err)
}
} else if clusterConfig.CertificateAuthority != "" {
caCerts, err = clientcertutil.CertsFromFile(clusterConfig.CertificateAuthority)
if err != nil {
return "", fmt.Errorf("failed to load CA certificate referenced by kubeconfig, err: %w", err)
}
} else {
return "", fmt.Errorf("no CA certificates found in kubeconfig")
}
// hash all the CA certs and include their public key pins as trusted values
publicKeyPins := make([]string, 0, len(caCerts))
for _, caCert := range caCerts {
publicKeyPins = append(publicKeyPins, pubkeypin.Hash(caCert))
}
return fmt.Sprintf("%s register %s --token %s --discovery-token-ca-cert-hash %s",
parentCommand, strings.Replace(clusterConfig.Server, "https://", "", -1),
token, strings.Join(publicKeyPins, ",")), nil
}
// GetClusterFromKubeConfig returns the Cluster of the specified KubeConfig, if karmada-context unset, it will use the current-context
func GetClusterFromKubeConfig(config *clientcmdapi.Config, karmadaContext string) *clientcmdapi.Cluster {
// If there is an unnamed cluster object, use it
if config.Clusters[""] != nil {
return config.Clusters[""]
}
if karmadaContext == "" {
karmadaContext = config.CurrentContext
}
if config.Contexts[karmadaContext] != nil {
return config.Clusters[config.Contexts[karmadaContext].Cluster]
}
return nil
}
// GenerateRandomBootstrapToken generate random bootstrap token
func GenerateRandomBootstrapToken(ttl *metav1.Duration, description string, groups, usages []string) (*BootstrapToken, error) {
tokenStr, err := bootstraputil.GenerateBootstrapToken()
if err != nil {
return nil, fmt.Errorf("couldn't generate random token, err: %w", err)
}
token, err := NewToken(tokenStr)
if err != nil {
return nil, err
}
bt := &BootstrapToken{
Token: token,
TTL: ttl,
Description: description,
Groups: groups,
Usages: usages,
}
return bt, nil
}
// NewToken converts the given Bootstrap Token as a string
// to the Token object used for serialization/deserialization
// and internal usage. It also automatically validates that the given token
// is of the right format
func NewToken(token string) (*Token, error) {
substrs := bootstraputil.BootstrapTokenRegexp.FindStringSubmatch(token)
if len(substrs) != validatedSubstringsSize {
return nil, fmt.Errorf("the bootstrap token %q was not of the form %q", token, bootstrapapi.BootstrapTokenPattern)
}
return &Token{ID: substrs[1], Secret: substrs[2]}, nil
}
// ConvertBootstrapTokenToSecret converts the given BootstrapToken object to its Secret representation that
// may be submitted to the API Server in order to be stored.
func ConvertBootstrapTokenToSecret(bt *BootstrapToken) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: bootstraputil.BootstrapTokenSecretName(bt.Token.ID),
Namespace: metav1.NamespaceSystem,
},
Type: corev1.SecretType(bootstrapapi.SecretTypeBootstrapToken),
Data: encodeTokenSecretData(bt, time.Now()),
}
}
// encodeTokenSecretData takes the token discovery object and an optional duration and returns the .Data for the Secret
// now is passed in order to be able to used in unit testing
func encodeTokenSecretData(token *BootstrapToken, now time.Time) map[string][]byte {
data := map[string][]byte{
bootstrapapi.BootstrapTokenIDKey: []byte(token.Token.ID),
bootstrapapi.BootstrapTokenSecretKey: []byte(token.Token.Secret),
}
if len(token.Description) > 0 {
data[bootstrapapi.BootstrapTokenDescriptionKey] = []byte(token.Description)
}
// If for some strange reason both token.TTL and token.Expires would be set
// (they are mutually exclusive in validation so this shouldn't be the case),
// token.Expires has higher priority, as can be seen in the logic here.
if token.Expires != nil {
// Format the expiration date accordingly
// TODO: This maybe should be a helper function in bootstraputil?
expirationString := token.Expires.Time.UTC().Format(time.RFC3339)
data[bootstrapapi.BootstrapTokenExpirationKey] = []byte(expirationString)
} else if token.TTL != nil && token.TTL.Duration > 0 {
// Only if .Expires is unset, TTL might have an effect
// Get the current time, add the specified duration, and format it accordingly
expirationString := now.Add(token.TTL.Duration).UTC().Format(time.RFC3339)
data[bootstrapapi.BootstrapTokenExpirationKey] = []byte(expirationString)
}
for _, usage := range token.Usages {
data[bootstrapapi.BootstrapTokenUsagePrefix+usage] = []byte("true")
}
if len(token.Groups) > 0 {
data[bootstrapapi.BootstrapTokenExtraGroupsKey] = []byte(strings.Join(token.Groups, ","))
}
return data
}
// NewTokenFromIDAndSecret is a wrapper around NewToken
// that allows the caller to specify the ID and Secret separately
func NewTokenFromIDAndSecret(id, secret string) (*Token, error) {
return NewToken(bootstraputil.TokenFromIDAndSecret(id, secret))
}
// GetBootstrapTokenFromSecret returns a BootstrapToken object from the given Secret
func GetBootstrapTokenFromSecret(secret *corev1.Secret) (*BootstrapToken, error) {
// Get the Token ID field from the Secret data
tokenID := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenIDKey)
if len(tokenID) == 0 {
return nil, fmt.Errorf("bootstrap Token Secret has no token-id data: %s", secret.Name)
}
// Enforce the right naming convention
if secret.Name != bootstraputil.BootstrapTokenSecretName(tokenID) {
return nil, fmt.Errorf("bootstrap token name is not of the form '%s(token-id)'. Actual: %q. Expected: %q",
bootstrapapi.BootstrapTokenSecretPrefix, secret.Name, bootstraputil.BootstrapTokenSecretName(tokenID))
}
tokenSecret := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenSecretKey)
if len(tokenSecret) == 0 {
return nil, fmt.Errorf("bootstrap Token Secret has no token-secret data: %s", secret.Name)
}
// Create the Token object based on the ID and Secret
bts, err := NewTokenFromIDAndSecret(tokenID, tokenSecret)
if err != nil {
return nil, fmt.Errorf("bootstrap Token Secret is invalid and couldn't be parsed, err: %w", err)
}
// Get the description (if any) from the Secret
description := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenDescriptionKey)
// Expiration time is optional, if not specified this implies the token
// never expires.
secretExpiration := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenExpirationKey)
var expires *metav1.Time
if len(secretExpiration) > 0 {
expTime, err := time.Parse(time.RFC3339, secretExpiration)
if err != nil {
return nil, fmt.Errorf("can't parse expiration time of bootstrap token %q, err: %w", secret.Name, err)
}
expires = &metav1.Time{Time: expTime}
}
// Build an usages string slice from the Secret data
var usages []string
for k, v := range secret.Data {
// Skip all fields that don't include this prefix
if !strings.HasPrefix(k, bootstrapapi.BootstrapTokenUsagePrefix) {
continue
}
// Skip those that don't have this usage set to true
if string(v) != "true" {
continue
}
usages = append(usages, strings.TrimPrefix(k, bootstrapapi.BootstrapTokenUsagePrefix))
}
// Only sort the slice if defined
if usages != nil {
sort.Strings(usages)
}
// Get the extra groups information from the Secret
// It's done this way to make .Groups be nil in case there is no items, rather than an
// empty slice or an empty slice with a "" string only
var groups []string
groupsString := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenExtraGroupsKey)
g := strings.Split(groupsString, ",")
if len(g) > 0 && len(g[0]) > 0 {
groups = g
}
return &BootstrapToken{
Token: bts,
Description: description,
Expires: expires,
Usages: usages,
Groups: groups,
}, nil
}
// CreateNewToken tries to create a token and fails if one with the same ID already exists
func CreateNewToken(client kubeclient.Interface, token *BootstrapToken) error {
return UpdateOrCreateToken(client, true, token)
}
// UpdateOrCreateToken attempts to update a token with the given ID, or create if it does not already exist.
func UpdateOrCreateToken(client kubeclient.Interface, failIfExists bool, token *BootstrapToken) error {
secretName := bootstraputil.BootstrapTokenSecretName(token.Token.ID)
secret, err := client.CoreV1().Secrets(metav1.NamespaceSystem).Get(context.TODO(), secretName, metav1.GetOptions{})
if secret != nil && err == nil && failIfExists {
return fmt.Errorf("a token with id %q already exists", token.Token.ID)
}
updatedOrNewSecret := ConvertBootstrapTokenToSecret(token)
// Try to create or update the token with an exponential backoff
err = TryRunCommand(func() error {
if err := cmdutil.CreateOrUpdateSecret(client, updatedOrNewSecret); err != nil {
return fmt.Errorf("failed to create or update bootstrap token with name %s, err: %w", secretName, err)
}
return nil
}, 5)
if err != nil {
return err
}
return nil
}
// TryRunCommand runs a function a maximum of failureThreshold times, and retries on error. If failureThreshold is hit; the last error is returned
func TryRunCommand(f func() error, failureThreshold int) error {
backoff := wait.Backoff{
Duration: 5 * time.Second,
Factor: 2, // double the timeout for every failure
Steps: failureThreshold,
}
return wait.ExponentialBackoff(backoff, func() (bool, error) {
err := f()
if err != nil {
// Retry until the timeout
return false, nil
}
// The last f() call was a success, return cleanly
return true, nil
})
}