Implement karmadactl token command to manage bootstrap tokens
Signed-off-by: lonelyCZ <531187475@qq.com>
This commit is contained in:
parent
c10e5f6863
commit
fbc9599510
|
@ -67,6 +67,7 @@ func NewKarmadaCtlCommand(cmdUse, parentCommand string) *cobra.Command {
|
||||||
addons.NewCommandAddons(parentCommand),
|
addons.NewCommandAddons(parentCommand),
|
||||||
NewCmdJoin(karmadaConfig, parentCommand),
|
NewCmdJoin(karmadaConfig, parentCommand),
|
||||||
NewCmdUnjoin(karmadaConfig, parentCommand),
|
NewCmdUnjoin(karmadaConfig, parentCommand),
|
||||||
|
NewCmdToken(karmadaConfig, parentCommand, ioStreams),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,378 @@
|
||||||
|
package karmadactl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/fields"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
"k8s.io/apimachinery/pkg/util/duration"
|
||||||
|
"k8s.io/cli-runtime/pkg/genericclioptions"
|
||||||
|
kubeclient "k8s.io/client-go/kubernetes"
|
||||||
|
bootstrapapi "k8s.io/cluster-bootstrap/token/api"
|
||||||
|
bootstraputil "k8s.io/cluster-bootstrap/token/util"
|
||||||
|
"k8s.io/klog/v2"
|
||||||
|
"k8s.io/kubectl/pkg/cmd/get"
|
||||||
|
"k8s.io/kubectl/pkg/util/templates"
|
||||||
|
|
||||||
|
"github.com/karmada-io/karmada/pkg/karmadactl/options"
|
||||||
|
tokenutil "github.com/karmada-io/karmada/pkg/karmadactl/util/bootstraptoken"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
tokenLong = templates.LongDesc(`
|
||||||
|
This command manages bootstrap tokens. It is optional and needed only for advanced use cases.
|
||||||
|
|
||||||
|
In short, bootstrap tokens are used for establishing bidirectional trust between a client and a server.
|
||||||
|
A bootstrap token can be used when a client (for example a member cluster that is about to join control plane) needs
|
||||||
|
to trust the server it is talking to. Then a bootstrap token with the "signing" usage can be used.
|
||||||
|
bootstrap tokens can also function as a way to allow short-lived authentication to the API Server
|
||||||
|
(the token serves as a way for the API Server to trust the client), for example for doing the TLS Bootstrap.
|
||||||
|
|
||||||
|
What is a bootstrap token more exactly?
|
||||||
|
- It is a Secret in the kube-system namespace of type "bootstrap.kubernetes.io/token".
|
||||||
|
- A bootstrap token must be of the form "[a-z0-9]{6}.[a-z0-9]{16}". The former part is the public token ID,
|
||||||
|
while the latter is the Token Secret and it must be kept private at all circumstances!
|
||||||
|
- The name of the Secret must be named "bootstrap-token-(token-id)".
|
||||||
|
|
||||||
|
This command is same as 'kubeadm token', but it will create tokens that are used by member clusters.`)
|
||||||
|
|
||||||
|
tokenExamples = templates.Examples(`
|
||||||
|
# Create a token and print the full '%[1]s register' flag needed to join the cluster using the token.
|
||||||
|
%[1]s token create --print-register-command
|
||||||
|
`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewCmdToken returns cobra.Command for token management
|
||||||
|
func NewCmdToken(karmadaConfig KarmadaConfig, parentCommand string, streams genericclioptions.IOStreams) *cobra.Command {
|
||||||
|
opts := &CommandTokenOptions{
|
||||||
|
parentCommand: parentCommand,
|
||||||
|
TTL: &metav1.Duration{
|
||||||
|
Duration: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "token",
|
||||||
|
Short: "Manage bootstrap tokens",
|
||||||
|
Long: tokenLong,
|
||||||
|
Example: fmt.Sprintf(tokenExamples, parentCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(NewCmdTokenCreate(karmadaConfig, streams.Out, opts))
|
||||||
|
cmd.AddCommand(NewCmdTokenList(karmadaConfig, streams.Out, streams.ErrOut, opts))
|
||||||
|
cmd.AddCommand(NewCmdTokenDelete(karmadaConfig, streams.Out, opts))
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandTokenOptions holds all command options for token
|
||||||
|
type CommandTokenOptions struct {
|
||||||
|
// global flags
|
||||||
|
options.GlobalCommandOptions
|
||||||
|
|
||||||
|
TTL *metav1.Duration
|
||||||
|
Description string
|
||||||
|
Groups []string
|
||||||
|
Usages []string
|
||||||
|
|
||||||
|
PrintRegisterCommand bool
|
||||||
|
|
||||||
|
parentCommand string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCmdTokenCreate returns cobra.Command to create token
|
||||||
|
func NewCmdTokenCreate(karmadaConfig KarmadaConfig, out io.Writer, tokenOpts *CommandTokenOptions) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
DisableFlagsInUseLine: true,
|
||||||
|
Short: "Create bootstrap tokens on the server",
|
||||||
|
Long: templates.LongDesc(`
|
||||||
|
This command will create a bootstrap token for you.
|
||||||
|
You can specify the usages for this token, the "time to live" and an optional human friendly description.
|
||||||
|
|
||||||
|
This should be a securely generated random token of the form "[a-z0-9]{6}.[a-z0-9]{16}".
|
||||||
|
`),
|
||||||
|
RunE: func(Cmd *cobra.Command, args []string) error {
|
||||||
|
// Get control plane kube-apiserver client
|
||||||
|
client, err := tokenOpts.getClientSet(karmadaConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenOpts.runCreateToken(out, client)
|
||||||
|
},
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenOpts.GlobalCommandOptions.AddFlags(cmd.Flags())
|
||||||
|
|
||||||
|
if tokenOpts.KubeConfig == "" {
|
||||||
|
env := os.Getenv("KUBECONFIG")
|
||||||
|
if env != "" {
|
||||||
|
tokenOpts.KubeConfig = env
|
||||||
|
} else {
|
||||||
|
tokenOpts.KubeConfig = defaultKubeConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&tokenOpts.PrintRegisterCommand, "print-register-command", false, fmt.Sprintf("Instead of printing only the token, print the full '%s join' flag needed to join the member cluster using the token.", tokenOpts.parentCommand))
|
||||||
|
cmd.Flags().DurationVar(&tokenOpts.TTL.Duration, "ttl", tokenutil.DefaultTokenDuration, "The duration before the token is automatically deleted (e.g. 1s, 2m, 3h). If set to '0', the token will never expire")
|
||||||
|
cmd.Flags().StringSliceVar(&tokenOpts.Usages, "usages", tokenutil.DefaultUsages, fmt.Sprintf("Describes the ways in which this token can be used. You can pass --usages multiple times or provide a comma separated list of options. Valid options: [%s]", strings.Join(bootstrapapi.KnownTokenUsages, ",")))
|
||||||
|
cmd.Flags().StringSliceVar(&tokenOpts.Groups, "groups", tokenutil.DefaultGroups, fmt.Sprintf("Extra groups that this token will authenticate as when used for authentication. Must match %q", bootstrapapi.BootstrapGroupPattern))
|
||||||
|
cmd.Flags().StringVar(&tokenOpts.Description, "description", tokenOpts.Description, "A human friendly description of how this token is used.")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCmdTokenList returns cobra.Command to list tokens
|
||||||
|
func NewCmdTokenList(karmadaConfig KarmadaConfig, out io.Writer, errW io.Writer, tokenOpts *CommandTokenOptions) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List bootstrap tokens on the server",
|
||||||
|
Long: "This command will list all bootstrap tokens for you.",
|
||||||
|
RunE: func(tokenCmd *cobra.Command, args []string) error {
|
||||||
|
// Get control plane kube-apiserver client
|
||||||
|
client, err := tokenOpts.getClientSet(karmadaConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenOpts.runListTokens(client, out, errW)
|
||||||
|
},
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenOpts.GlobalCommandOptions.AddFlags(cmd.Flags())
|
||||||
|
|
||||||
|
if tokenOpts.KubeConfig == "" {
|
||||||
|
env := os.Getenv("KUBECONFIG")
|
||||||
|
if env != "" {
|
||||||
|
tokenOpts.KubeConfig = env
|
||||||
|
} else {
|
||||||
|
tokenOpts.KubeConfig = defaultKubeConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCmdTokenDelete returns cobra.Command to delete tokens
|
||||||
|
func NewCmdTokenDelete(karmadaConfig KarmadaConfig, out io.Writer, tokenOpts *CommandTokenOptions) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "delete [token-value] ...",
|
||||||
|
DisableFlagsInUseLine: true,
|
||||||
|
Short: "Delete bootstrap tokens on the server",
|
||||||
|
Long: templates.LongDesc(`
|
||||||
|
This command will delete a list of bootstrap tokens for you.
|
||||||
|
|
||||||
|
The [token-value] is the full Token of the form "[a-z0-9]{6}.[a-z0-9]{16}" or the
|
||||||
|
Token ID of the form "[a-z0-9]{6}" to delete.
|
||||||
|
`),
|
||||||
|
RunE: func(tokenCmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return fmt.Errorf("missing subcommand; 'token delete' is missing token of form %q", bootstrapapi.BootstrapTokenIDPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get control plane kube-apiserver client
|
||||||
|
client, err := tokenOpts.getClientSet(karmadaConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenOpts.runDeleteTokens(out, client, args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenOpts.GlobalCommandOptions.AddFlags(cmd.Flags())
|
||||||
|
|
||||||
|
if tokenOpts.KubeConfig == "" {
|
||||||
|
env := os.Getenv("KUBECONFIG")
|
||||||
|
if env != "" {
|
||||||
|
tokenOpts.KubeConfig = env
|
||||||
|
} else {
|
||||||
|
tokenOpts.KubeConfig = defaultKubeConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCreateToken generates a new bootstrap token and stores it as a secret on the server.
|
||||||
|
func (o *CommandTokenOptions) runCreateToken(out io.Writer, client kubeclient.Interface) error {
|
||||||
|
klog.V(1).Infoln("[token] creating token")
|
||||||
|
bootstrapToken, err := tokenutil.GenerateRandomBootstrapToken(o.TTL, o.Description, o.Groups, o.Usages)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tokenutil.CreateNewToken(client, bootstrapToken); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr := bootstrapToken.Token.ID + "." + bootstrapToken.Token.Secret
|
||||||
|
|
||||||
|
// if --print-register-command was specified, print a machine-readable full `karmadactl register` command
|
||||||
|
// otherwise, just print the token
|
||||||
|
if o.PrintRegisterCommand {
|
||||||
|
joinCommand, err := tokenutil.GenerateRegisterCommand(o.KubeConfig, o.parentCommand, tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get register command, err: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out, joinCommand)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(out, tokenStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runListTokens lists details on all existing bootstrap tokens on the server.
|
||||||
|
func (o *CommandTokenOptions) runListTokens(client kubeclient.Interface, out io.Writer, errW io.Writer) error {
|
||||||
|
// First, build our selector for bootstrap tokens only
|
||||||
|
klog.V(1).Infoln("[token] preparing selector for bootstrap token")
|
||||||
|
tokenSelector := fields.SelectorFromSet(
|
||||||
|
map[string]string{
|
||||||
|
"type": string(bootstrapapi.SecretTypeBootstrapToken),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
listOptions := metav1.ListOptions{
|
||||||
|
FieldSelector: tokenSelector.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
klog.V(1).Info("[token] retrieving list of bootstrap tokens")
|
||||||
|
secrets, err := client.CoreV1().Secrets(metav1.NamespaceSystem).List(context.TODO(), listOptions)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list bootstrap tokens, err: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
printFlags := get.NewGetPrintFlags()
|
||||||
|
|
||||||
|
printFlags.SetKind(schema.GroupKind{Group: "output.karmada.io", Kind: "BootstrapToken"})
|
||||||
|
|
||||||
|
printer, err := printFlags.ToPrinter()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// only print token with table format
|
||||||
|
printer = &get.TablePrinter{Delegate: printer}
|
||||||
|
|
||||||
|
tokenTable := &metav1.Table{}
|
||||||
|
setColumnDefinition(tokenTable)
|
||||||
|
|
||||||
|
for idx := range secrets.Items {
|
||||||
|
// Get the BootstrapToken struct representation from the Secret object
|
||||||
|
token, err := tokenutil.GetBootstrapTokenFromSecret(&secrets.Items[idx])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errW, "%v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
outputToken := tokenutil.BootstrapToken{
|
||||||
|
Token: &tokenutil.Token{ID: token.Token.ID, Secret: token.Token.Secret},
|
||||||
|
Description: token.Description,
|
||||||
|
TTL: token.TTL,
|
||||||
|
Expires: token.Expires,
|
||||||
|
Usages: token.Usages,
|
||||||
|
Groups: token.Groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenTable.Rows = append(tokenTable.Rows, constructTokenTableRow(outputToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := printer.PrintObj(tokenTable, out); err != nil {
|
||||||
|
return fmt.Errorf("unable to print tokens, err: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDeleteTokens removes a bootstrap tokens from the server.
|
||||||
|
func (o *CommandTokenOptions) runDeleteTokens(out io.Writer, client kubeclient.Interface, tokenIDsOrTokens []string) error {
|
||||||
|
for _, tokenIDOrToken := range tokenIDsOrTokens {
|
||||||
|
// Assume this is a token id and try to parse it
|
||||||
|
tokenID := tokenIDOrToken
|
||||||
|
klog.V(1).Info("[token] parsing token")
|
||||||
|
if !bootstraputil.IsValidBootstrapTokenID(tokenIDOrToken) {
|
||||||
|
// Okay, the full token with both id and secret was probably passed. Parse it and extract the ID only
|
||||||
|
bts, err := tokenutil.NewToken(tokenIDOrToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("given token didn't match pattern %q or %q",
|
||||||
|
bootstrapapi.BootstrapTokenIDPattern, bootstrapapi.BootstrapTokenIDPattern)
|
||||||
|
}
|
||||||
|
tokenID = bts.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenSecretName := bootstraputil.BootstrapTokenSecretName(tokenID)
|
||||||
|
klog.V(1).Infof("[token] deleting token %q", tokenID)
|
||||||
|
if err := client.CoreV1().Secrets(metav1.NamespaceSystem).Delete(context.TODO(), tokenSecretName, metav1.DeleteOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete bootstrap token %q, err: %w", tokenID, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "bootstrap token %q deleted\n", tokenID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientSet get clientset of karmada control plane
|
||||||
|
func (o *CommandTokenOptions) getClientSet(karmadaConfig KarmadaConfig) (kubeclient.Interface, error) {
|
||||||
|
// Get control plane karmada-apiserver client
|
||||||
|
controlPlaneRestConfig, err := karmadaConfig.GetRestConfig(o.KarmadaContext, o.KubeConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get control plane rest config. context: %s, kube-config: %s, error: %v",
|
||||||
|
o.KarmadaContext, o.KubeConfig, err)
|
||||||
|
}
|
||||||
|
return kubeclient.NewForConfigOrDie(controlPlaneRestConfig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// constructTokenTableRow construct token table row
|
||||||
|
func constructTokenTableRow(token tokenutil.BootstrapToken) metav1.TableRow {
|
||||||
|
var row metav1.TableRow
|
||||||
|
|
||||||
|
tokenString := token.Token.ID + "." + token.Token.Secret
|
||||||
|
|
||||||
|
ttl := "<forever>"
|
||||||
|
expires := "<never>"
|
||||||
|
if token.Expires != nil {
|
||||||
|
ttl = duration.ShortHumanDuration(time.Until(token.Expires.Time))
|
||||||
|
expires = token.Expires.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
usages := strings.Join(token.Usages, ",")
|
||||||
|
if len(usages) == 0 {
|
||||||
|
usages = "<none>"
|
||||||
|
}
|
||||||
|
|
||||||
|
description := token.Description
|
||||||
|
if len(description) == 0 {
|
||||||
|
description = "<none>"
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := strings.Join(token.Groups, ",")
|
||||||
|
if len(groups) == 0 {
|
||||||
|
groups = "<none>"
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Cells = append(row.Cells, tokenString, ttl, expires, usages, description, groups)
|
||||||
|
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// setColumnDefinition set print ColumnDefinition
|
||||||
|
func setColumnDefinition(table *metav1.Table) {
|
||||||
|
tokenColumns := []metav1.TableColumnDefinition{
|
||||||
|
{Name: "TOKEN", Type: "string", Format: "", Priority: 0},
|
||||||
|
{Name: "TTL", Type: "string", Format: "", Priority: 0},
|
||||||
|
{Name: "EXPIRES", Type: "string", Format: "", Priority: 0},
|
||||||
|
{Name: "USAGES", Type: "string", Format: "", Priority: 0},
|
||||||
|
{Name: "DESCRIPTION", Type: "string", Format: "", Priority: 0},
|
||||||
|
{Name: "EXTRA GROUPS", Type: "string", Format: "", Priority: 0},
|
||||||
|
}
|
||||||
|
table.ColumnDefinitions = tokenColumns
|
||||||
|
}
|
|
@ -0,0 +1,353 @@
|
||||||
|
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"
|
||||||
|
|
||||||
|
karmadautil "github.com/karmada-io/karmada/pkg/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) (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 default cluster config
|
||||||
|
clusterConfig := GetClusterFromKubeConfig(config)
|
||||||
|
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 default Cluster of the specified KubeConfig
|
||||||
|
func GetClusterFromKubeConfig(config *clientcmdapi.Config) *clientcmdapi.Cluster {
|
||||||
|
// If there is an unnamed cluster object, use it
|
||||||
|
if config.Clusters[""] != nil {
|
||||||
|
return config.Clusters[""]
|
||||||
|
}
|
||||||
|
if config.Contexts[config.CurrentContext] != nil {
|
||||||
|
return config.Clusters[config.Contexts[config.CurrentContext].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 := karmadautil.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
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
/*
|
||||||
|
Copyright 2017 The Kubernetes Authors.
|
||||||
|
|
||||||
|
Licensed 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 pubkeypin provides primitives for x509 public key pinning in the
|
||||||
|
// style of RFC7469.
|
||||||
|
|
||||||
|
// This code is directly lifted from the Kubernetes codebase in order to avoid relying on the k8s.io/kubernetes package.
|
||||||
|
// For reference:
|
||||||
|
// https://github.com/kubernetes/kubernetes/blob/release-1.24/cmd/kubeadm/app/util/pubkeypin/pubkeypin.go
|
||||||
|
|
||||||
|
package pubkeypin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// formatSHA256 is the prefix for pins that are full-length SHA-256 hashes encoded in base 16 (hex)
|
||||||
|
formatSHA256 = "sha256"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// supportedFormats enumerates the supported formats
|
||||||
|
supportedFormats = strings.Join([]string{formatSHA256}, ", ")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set is a set of pinned x509 public keys.
|
||||||
|
type Set struct {
|
||||||
|
sha256Hashes map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSet returns a new, empty PubKeyPinSet
|
||||||
|
func NewSet() *Set {
|
||||||
|
return &Set{make(map[string]bool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow adds an allowed public key hash to the Set
|
||||||
|
func (s *Set) Allow(pubKeyHashes ...string) error {
|
||||||
|
for _, pubKeyHash := range pubKeyHashes {
|
||||||
|
parts := strings.Split(pubKeyHash, ":")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return fmt.Errorf("invalid hash, expected \"format:hex-value\". "+
|
||||||
|
"Known format(s) are: %s", supportedFormats)
|
||||||
|
}
|
||||||
|
format, value := parts[0], parts[1]
|
||||||
|
|
||||||
|
switch strings.ToLower(format) {
|
||||||
|
case "sha256":
|
||||||
|
if err := s.allowSHA256(value); err != nil {
|
||||||
|
return fmt.Errorf("invalid hash %q, %v", pubKeyHash, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown hash format %q. Known format(s) are: %s", format, supportedFormats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAny checks if at least one certificate matches one of the public keys in the set
|
||||||
|
func (s *Set) CheckAny(certificates []*x509.Certificate) error {
|
||||||
|
var hashes []string
|
||||||
|
|
||||||
|
for _, certificate := range certificates {
|
||||||
|
if s.checkSHA256(certificate) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hashes = append(hashes, Hash(certificate))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("none of the public keys %q are pinned", strings.Join(hashes, ":"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty returns true if the Set contains no pinned public keys.
|
||||||
|
func (s *Set) Empty() bool {
|
||||||
|
return len(s.sha256Hashes) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash calculates the SHA-256 hash of the Subject Public Key Information (SPKI)
|
||||||
|
// object in an x509 certificate (in DER encoding). It returns the full hash as a
|
||||||
|
// hex encoded string (suitable for passing to Set.Allow).
|
||||||
|
func Hash(certificate *x509.Certificate) string {
|
||||||
|
spkiHash := sha256.Sum256(certificate.RawSubjectPublicKeyInfo)
|
||||||
|
return formatSHA256 + ":" + strings.ToLower(hex.EncodeToString(spkiHash[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowSHA256 validates a "sha256" format hash and adds a canonical version of it into the Set
|
||||||
|
func (s *Set) allowSHA256(hash string) error {
|
||||||
|
// validate that the hash is the right length to be a full SHA-256 hash
|
||||||
|
hashLength := hex.DecodedLen(len(hash))
|
||||||
|
if hashLength != sha256.Size {
|
||||||
|
return fmt.Errorf("expected a %d byte SHA-256 hash, found %d bytes", sha256.Size, hashLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate that the hash is valid hex
|
||||||
|
_, err := hex.DecodeString(hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not decode SHA-256 from hex, err: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// in the end, just store the original hex string in memory (in lowercase)
|
||||||
|
s.sha256Hashes[strings.ToLower(hash)] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSHA256 returns true if the certificate's "sha256" hash is pinned in the Set
|
||||||
|
func (s *Set) checkSHA256(certificate *x509.Certificate) bool {
|
||||||
|
actualHash := sha256.Sum256(certificate.RawSubjectPublicKeyInfo)
|
||||||
|
actualHashHex := strings.ToLower(hex.EncodeToString(actualHash[:]))
|
||||||
|
return s.sha256Hashes[actualHashHex]
|
||||||
|
}
|
|
@ -0,0 +1,168 @@
|
||||||
|
/*
|
||||||
|
Copyright 2017 The Kubernetes Authors.
|
||||||
|
|
||||||
|
Licensed 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 pubkeypin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testCertPEM is a simple self-signed test certificate issued with the openssl CLI:
|
||||||
|
// openssl req -new -newkey rsa:2048 -days 36500 -nodes -x509 -keyout /dev/null -out test.crt
|
||||||
|
const testCertPEM = `
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDRDCCAiygAwIBAgIJAJgVaCXvC6HkMA0GCSqGSIb3DQEBBQUAMB8xHTAbBgNV
|
||||||
|
BAMTFGt1YmVhZG0ta2V5cGlucy10ZXN0MCAXDTE3MDcwNTE3NDMxMFoYDzIxMTcw
|
||||||
|
NjExMTc0MzEwWjAfMR0wGwYDVQQDExRrdWJlYWRtLWtleXBpbnMtdGVzdDCCASIw
|
||||||
|
DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK0ba8mHU9UtYlzM1Own2Fk/XGjR
|
||||||
|
J4uJQvSeGLtz1hID1IA0dLwruvgLCPadXEOw/f/IWIWcmT+ZmvIHZKa/woq2iHi5
|
||||||
|
+HLhXs7aG4tjKGLYhag1hLjBI7icqV7ovkjdGAt9pWkxEzhIYClFMXDjKpMSynu+
|
||||||
|
YX6nZ9tic1cOkHmx2yiZdMkuriRQnpTOa7bb03OC1VfGl7gHlOAIYaj4539WCOr8
|
||||||
|
+ACTUMJUFEHcRZ2o8a/v6F9GMK+7SC8SJUI+GuroXqlMAdhEv4lX5Co52enYaClN
|
||||||
|
+D9FJLRpBv2YfiCQdJRaiTvCBSxEFz6BN+PtP5l2Hs703ZWEkOqCByM6HV8CAwEA
|
||||||
|
AaOBgDB+MB0GA1UdDgQWBBRQgUX8MhK2rWBWQiPHWcKzoWDH5DBPBgNVHSMESDBG
|
||||||
|
gBRQgUX8MhK2rWBWQiPHWcKzoWDH5KEjpCEwHzEdMBsGA1UEAxMUa3ViZWFkbS1r
|
||||||
|
ZXlwaW5zLXRlc3SCCQCYFWgl7wuh5DAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEB
|
||||||
|
BQUAA4IBAQCaAUif7Pfx3X0F08cxhx8/Hdx4jcJw6MCq6iq6rsXM32ge43t8OHKC
|
||||||
|
pJW08dk58a3O1YQSMMvD6GJDAiAfXzfwcwY6j258b1ZlI9Ag0VokvhMl/XfdCsdh
|
||||||
|
AWImnL1t4hvU5jLaImUUMlYxMcSfHBGAm7WJIZ2LdEfg6YWfZh+WGbg1W7uxLxk6
|
||||||
|
y4h5rWdNnzBHWAGf7zJ0oEDV6W6RSwNXtC0JNnLaeIUm/6xdSddJlQPwUv8YH4jX
|
||||||
|
c1vuFqTnJBPcb7W//R/GI2Paicm1cmns9NLnPR35exHxFTy+D1yxmGokpoPMdife
|
||||||
|
aH+sfuxT8xeTPb3kjzF9eJTlnEquUDLM
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
|
// expectedHash can be verified using the openssl CLI.
|
||||||
|
const expectedHash = `sha256:345959acb2c3b2feb87d281961c893f62a314207ef02599f1cc4a5fb255480b3`
|
||||||
|
|
||||||
|
// testCert2PEM is a second test cert generated the same way as testCertPEM
|
||||||
|
const testCert2PEM = `
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIID9jCCAt6gAwIBAgIJAN5MXZDic7qYMA0GCSqGSIb3DQEBBQUAMFkxCzAJBgNV
|
||||||
|
BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
|
||||||
|
aWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMTCXRlc3RDZXJ0MjAgFw0xNzA3MjQxNjA0
|
||||||
|
MDFaGA8yMTE3MDYzMDE2MDQwMVowWTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNv
|
||||||
|
bWUtU3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAG
|
||||||
|
A1UEAxMJdGVzdENlcnQyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
|
||||||
|
0brwpJYN2ytPWzRBtZSVc3dhkQlA59AzxzqeLLkano0Pxo9NIc3T/y58nnRI8uaS
|
||||||
|
I1P7BzUfJTiUEvmAtX8NggqKK4ld/gPrU+IRww1CUYS4KCkA/0d0ctPy0JwBCjD+
|
||||||
|
b57G3rmNE8c+0jns6J96ZzNtqmv6N+ZlFBAXm1p4S+k0kGi5+hoQ6H7SYXjk2lG+
|
||||||
|
r/8jPQEjy/NSdw1dcCA0Nc6o+hPr32927dS6J9KOhBeXNYUNdbuDDmroM9/gN2e/
|
||||||
|
YMSA1olLeDPQ7Xvhk0PIyEDnHh83AffPCx5yM3htVRGddjIsPAVUJEL3z5leJtxe
|
||||||
|
fzyPghOhHJY0PXqznDQTcwIDAQABo4G+MIG7MB0GA1UdDgQWBBRP0IJqv/5rQ4Uf
|
||||||
|
SByl77dJeEapRDCBiwYDVR0jBIGDMIGAgBRP0IJqv/5rQ4UfSByl77dJeEapRKFd
|
||||||
|
pFswWTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNVBAoT
|
||||||
|
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAxMJdGVzdENlcnQyggkA
|
||||||
|
3kxdkOJzupgwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEA0RIMHc10
|
||||||
|
wHHPMh9UflqBgDMF7gfbOL0juJfGloAOcohWWfMZBBJ0CQKMy3xRyoK3HmbW1eeb
|
||||||
|
iATjesw7t4VEAwf7mgKAd+eTfWYB952uq5qYJ2TI28mSofEq1Wz3RmrNkC1KCBs1
|
||||||
|
u+YMFGwyl6necV9zKCeiju4jeovI1GA38TvH7MgYln6vMJ+FbgOXj7XCpek7dQiY
|
||||||
|
KGaeSSH218mGNQaWRQw2Sm3W6cFdANoCJUph4w18s7gjtFpfV63s80hXRps+vEyv
|
||||||
|
jEQMEQpG8Ss7HGJLGLBw/xAmG0e//XS/o2dDonbGbvzToFByz8OGxjMhk6yV6hdd
|
||||||
|
+iyvsLAw/MYMSA==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
`
|
||||||
|
|
||||||
|
// testCert is a small helper to get a test x509.Certificate from the PEM constants
|
||||||
|
func testCert(t *testing.T, pemString string) *x509.Certificate {
|
||||||
|
// Decode the example certificate from a PEM file into a PEM block
|
||||||
|
pemBlock, _ := pem.Decode([]byte(pemString))
|
||||||
|
if pemBlock == nil {
|
||||||
|
t.Fatal("failed to parse test certificate PEM")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the PEM block into an x509.Certificate
|
||||||
|
result, err := x509.ParseCertificate(pemBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse test certificate: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSet(t *testing.T) {
|
||||||
|
s := NewSet()
|
||||||
|
if !s.Empty() {
|
||||||
|
t.Error("expected a new set to be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := s.Allow("xyz")
|
||||||
|
if err == nil || !s.Empty() {
|
||||||
|
t.Error("expected allowing junk to fail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Allow("0011223344")
|
||||||
|
if err == nil || !s.Empty() {
|
||||||
|
t.Error("expected allowing something too short to fail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Allow(expectedHash + expectedHash)
|
||||||
|
if err == nil || !s.Empty() {
|
||||||
|
t.Error("expected allowing something too long to fail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.CheckAny([]*x509.Certificate{testCert(t, testCertPEM)})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected test cert to not be allowed (yet)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Allow(strings.ToUpper(expectedHash))
|
||||||
|
if err != nil || s.Empty() {
|
||||||
|
t.Error("expected allowing uppercase expectedHash to succeed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.CheckAny([]*x509.Certificate{testCert(t, testCertPEM)})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected test cert to be allowed, but got back: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.CheckAny([]*x509.Certificate{testCert(t, testCert2PEM)})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected the second test cert to be disallowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s = NewSet() // keep set empty
|
||||||
|
hashes := []string{
|
||||||
|
`sha256:0000000000000000000000000000000000000000000000000000000000000000`,
|
||||||
|
`sha256:0000000000000000000000000000000000000000000000000000000000000001`,
|
||||||
|
}
|
||||||
|
err = s.Allow(hashes...)
|
||||||
|
if err != nil || len(s.sha256Hashes) != 2 {
|
||||||
|
t.Error("expected allowing multiple hashes to succeed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHash(t *testing.T) {
|
||||||
|
actualHash := Hash(testCert(t, testCertPEM))
|
||||||
|
if actualHash != expectedHash {
|
||||||
|
t.Errorf(
|
||||||
|
"failed to Hash() to the expected value\n\texpected: %q\n\t actual: %q",
|
||||||
|
expectedHash,
|
||||||
|
actualHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -83,3 +83,17 @@ func PatchSecret(client kubeclient.Interface, namespace, name string, pt types.P
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateOrUpdateSecret creates a Secret if the target resource doesn't exist. If the resource exists already, this function will update the resource instead.
|
||||||
|
func CreateOrUpdateSecret(client kubeclient.Interface, secret *corev1.Secret) error {
|
||||||
|
if _, err := client.CoreV1().Secrets(secret.ObjectMeta.Namespace).Create(context.TODO(), secret, metav1.CreateOptions{}); err != nil {
|
||||||
|
if !apierrors.IsAlreadyExists(err) {
|
||||||
|
return fmt.Errorf("unable to create secret, err: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.CoreV1().Secrets(secret.ObjectMeta.Namespace).Update(context.TODO(), secret, metav1.UpdateOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("unable to update secret, err: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,112 @@
|
||||||
|
/*
|
||||||
|
Copyright 2019 The Kubernetes Authors.
|
||||||
|
|
||||||
|
Licensed 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 secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
v1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/sets"
|
||||||
|
"k8s.io/cluster-bootstrap/token/api"
|
||||||
|
legacyutil "k8s.io/cluster-bootstrap/token/util"
|
||||||
|
"k8s.io/klog/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
secretNameRe = regexp.MustCompile(`^` + regexp.QuoteMeta(api.BootstrapTokenSecretPrefix) + `([a-z0-9]{6})$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetData returns the string value for the given key in the specified Secret
|
||||||
|
// If there is an error or if the key doesn't exist, an empty string is returned.
|
||||||
|
func GetData(secret *v1.Secret, key string) string {
|
||||||
|
if secret.Data == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if val, ok := secret.Data[key]; ok {
|
||||||
|
return string(val)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasExpired will identify whether the secret expires
|
||||||
|
func HasExpired(secret *v1.Secret, currentTime time.Time) bool {
|
||||||
|
_, expired := GetExpiration(secret, currentTime)
|
||||||
|
|
||||||
|
return expired
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExpiration checks if the secret expires
|
||||||
|
// isExpired indicates if the secret is already expired.
|
||||||
|
// timeRemaining indicates how long until it does expire.
|
||||||
|
// if the secret has no expiration timestamp, returns 0, false.
|
||||||
|
// if there is an error parsing the secret's expiration timestamp, returns 0, true.
|
||||||
|
func GetExpiration(secret *v1.Secret, currentTime time.Time) (timeRemaining time.Duration, isExpired bool) {
|
||||||
|
expiration := GetData(secret, api.BootstrapTokenExpirationKey)
|
||||||
|
if len(expiration) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
expTime, err := time.Parse(time.RFC3339, expiration)
|
||||||
|
if err != nil {
|
||||||
|
klog.V(3).Infof("Unparseable expiration time (%s) in %s/%s Secret: %v. Treating as expired.",
|
||||||
|
expiration, secret.Namespace, secret.Name, err)
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
timeRemaining = expTime.Sub(currentTime)
|
||||||
|
if timeRemaining <= 0 {
|
||||||
|
klog.V(3).Infof("Expired bootstrap token in %s/%s Secret: %v",
|
||||||
|
secret.Namespace, secret.Name, expiration)
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
return timeRemaining, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseName parses the name of the secret to extract the secret ID.
|
||||||
|
func ParseName(name string) (secretID string, ok bool) {
|
||||||
|
r := secretNameRe.FindStringSubmatch(name)
|
||||||
|
if r == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return r[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroups loads and validates the bootstrapapi.BootstrapTokenExtraGroupsKey
|
||||||
|
// key from the bootstrap token secret, returning a list of group names or an
|
||||||
|
// error if any of the group names are invalid.
|
||||||
|
func GetGroups(secret *v1.Secret) ([]string, error) {
|
||||||
|
// always include the default group
|
||||||
|
groups := sets.NewString(api.BootstrapDefaultGroup)
|
||||||
|
|
||||||
|
// grab any extra groups and if there are none, return just the default
|
||||||
|
extraGroupsString := GetData(secret, api.BootstrapTokenExtraGroupsKey)
|
||||||
|
if extraGroupsString == "" {
|
||||||
|
return groups.List(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate the names of the extra groups
|
||||||
|
for _, group := range strings.Split(extraGroupsString, ",") {
|
||||||
|
if err := legacyutil.ValidateBootstrapGroupName(group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
groups.Insert(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// return the result as a deduplicated, sorted list
|
||||||
|
return groups.List(), nil
|
||||||
|
}
|
|
@ -1299,6 +1299,7 @@ k8s.io/client-go/util/workqueue
|
||||||
## explicit; go 1.16
|
## explicit; go 1.16
|
||||||
k8s.io/cluster-bootstrap/token/api
|
k8s.io/cluster-bootstrap/token/api
|
||||||
k8s.io/cluster-bootstrap/token/util
|
k8s.io/cluster-bootstrap/token/util
|
||||||
|
k8s.io/cluster-bootstrap/util/secrets
|
||||||
# k8s.io/code-generator v0.24.2
|
# k8s.io/code-generator v0.24.2
|
||||||
## explicit; go 1.16
|
## explicit; go 1.16
|
||||||
k8s.io/code-generator
|
k8s.io/code-generator
|
||||||
|
|
Loading…
Reference in New Issue