diff --git a/CHANGELOG.md b/CHANGELOG.md index ec5596255b..b1ed8c762b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## UNRELEASED FEATURES: +* Control Plane + * Add `gossip-encryption-autogenerate` subcommand to generate a random 32 byte Kubernetes secret to be used as a gossip encryption key. [[GH-772](https://github.com/hashicorp/consul-k8s/pull/772)] * Helm Chart * Add automatic generation of gossip encryption with `global.gossipEncryption.autoGenerate=true`. [[GH-738](https://github.com/hashicorp/consul-k8s/pull/738)] * Add support for configuring resources for mesh gateway `service-init` container. [[GH-758](https://github.com/hashicorp/consul-k8s/pull/758)] diff --git a/control-plane/commands.go b/control-plane/commands.go index 465ccbdd50..db43863642 100644 --- a/control-plane/commands.go +++ b/control-plane/commands.go @@ -10,6 +10,7 @@ import ( cmdCreateFederationSecret "github.com/hashicorp/consul-k8s/control-plane/subcommand/create-federation-secret" cmdDeleteCompletedJob "github.com/hashicorp/consul-k8s/control-plane/subcommand/delete-completed-job" cmdGetConsulClientCA "github.com/hashicorp/consul-k8s/control-plane/subcommand/get-consul-client-ca" + cmdGossipEncryptionAutogenerate "github.com/hashicorp/consul-k8s/control-plane/subcommand/gossip-encryption-autogenerate" cmdInjectConnect "github.com/hashicorp/consul-k8s/control-plane/subcommand/inject-connect" cmdPartitionInit "github.com/hashicorp/consul-k8s/control-plane/subcommand/partition-init" cmdServerACLInit "github.com/hashicorp/consul-k8s/control-plane/subcommand/server-acl-init" @@ -88,6 +89,10 @@ func init() { "tls-init": func() (cli.Command, error) { return &cmdTLSInit.Command{UI: ui}, nil }, + + "gossip-encryption-autogenerate": func() (cli.Command, error) { + return &cmdGossipEncryptionAutogenerate.Command{UI: ui}, nil + }, } } diff --git a/control-plane/subcommand/gossip-encryption-autogenerate/command.go b/control-plane/subcommand/gossip-encryption-autogenerate/command.go new file mode 100644 index 0000000000..0383bc8f08 --- /dev/null +++ b/control-plane/subcommand/gossip-encryption-autogenerate/command.go @@ -0,0 +1,211 @@ +package gossipencryptionautogenerate + +import ( + "context" + "crypto/rand" + "encoding/base64" + "flag" + "fmt" + "sync" + + "github.com/hashicorp/consul-k8s/control-plane/subcommand" + "github.com/hashicorp/consul-k8s/control-plane/subcommand/common" + "github.com/hashicorp/consul-k8s/control-plane/subcommand/flags" + "github.com/hashicorp/go-hclog" + "github.com/mitchellh/cli" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type Command struct { + UI cli.Ui + + flags *flag.FlagSet + k8s *flags.K8SFlags + + // These flags determine where the Kubernetes secret will be stored. + flagNamespace string + flagSecretName string + flagSecretKey string + + flagLogLevel string + flagLogJSON bool + + k8sClient kubernetes.Interface + + log hclog.Logger + once sync.Once + ctx context.Context + help string +} + +// init is run once to set up usage documentation for flags. +func (c *Command) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + + c.flags.StringVar(&c.flagLogLevel, "log-level", "info", + "Log verbosity level. Supported values (in order of detail) are \"trace\", "+ + "\"debug\", \"info\", \"warn\", and \"error\".") + c.flags.BoolVar(&c.flagLogJSON, "log-json", false, "Enable or disable JSON output format for logging.") + c.flags.StringVar(&c.flagNamespace, "namespace", "", "Name of Kubernetes namespace where Consul and consul-k8s components are deployed.") + c.flags.StringVar(&c.flagSecretName, "secret-name", "", "Name of the secret to create.") + c.flags.StringVar(&c.flagSecretKey, "secret-key", "key", "Name of the secret key to create.") + + c.k8s = &flags.K8SFlags{} + flags.Merge(c.flags, c.k8s.Flags()) + + c.help = flags.Usage(help, c.flags) +} + +// Run parses input and creates a gossip secret in Kubernetes if none exists at the given namespace and secret name. +func (c *Command) Run(args []string) int { + c.once.Do(c.init) + + if err := c.flags.Parse(args); err != nil { + c.UI.Error(fmt.Sprintf("Failed to parse args: %v", err)) + return 1 + } + + if err := c.validateFlags(); err != nil { + c.UI.Error(fmt.Sprintf("Failed to validate flags: %v", err)) + return 1 + } + + var err error + c.log, err = common.Logger(c.flagLogLevel, c.flagLogJSON) + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + + if c.ctx == nil { + c.ctx = context.Background() + } + + if c.k8sClient == nil { + if err = c.createKubernetesClient(); err != nil { + c.UI.Error(fmt.Sprintf("Failed to create Kubernetes client: %v", err)) + return 1 + } + } + + if exists, err := c.doesKubernetesSecretExist(); err != nil { + c.UI.Error(fmt.Sprintf("Failed to check if Kubernetes secret exists: %v", err)) + return 1 + } else if exists { + // Safe exit if secret already exists. + c.UI.Info(fmt.Sprintf("A Kubernetes secret with the name `%s` already exists.", c.flagSecretName)) + return 0 + } + + gossipSecret, err := generateGossipSecret() + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to generate gossip secret: %v", err)) + return 1 + } + + // Create the Kubernetes secret object. + kubernetesSecret := v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: c.flagSecretName, + Namespace: c.flagNamespace, + }, + Data: map[string][]byte{ + c.flagSecretKey: []byte(gossipSecret), + }, + } + + // Write the secret to Kubernetes. + _, err = c.k8sClient.CoreV1().Secrets(c.flagNamespace).Create(c.ctx, &kubernetesSecret, metav1.CreateOptions{}) + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to create Kubernetes secret: %v", err)) + return 1 + } + + c.UI.Info(fmt.Sprintf("Successfully created Kubernetes secret `%s` in namespace `%s`.", c.flagSecretName, c.flagNamespace)) + return 0 +} + +// Help returns the command's help text. +func (c *Command) Help() string { + c.once.Do(c.init) + return c.help +} + +// Synopsis returns a one-line synopsis of the command. +func (c *Command) Synopsis() string { + return synopsis +} + +// validateFlags ensures that all required flags are set. +func (c *Command) validateFlags() error { + if c.flagNamespace == "" { + return fmt.Errorf("-namespace must be set") + } + + if c.flagSecretName == "" { + return fmt.Errorf("-secret-name must be set") + } + + return nil +} + +// createKubernetesClient creates a Kubernetes client on the command object. +func (c *Command) createKubernetesClient() error { + config, err := subcommand.K8SConfig(c.k8s.KubeConfig()) + if err != nil { + return fmt.Errorf("failed to create Kubernetes config: %v", err) + } + + c.k8sClient, err = kubernetes.NewForConfig(config) + if err != nil { + return fmt.Errorf("error initializing Kubernetes client: %s", err) + } + + return nil +} + +// doesKubernetesSecretExist checks if a secret with the given name exists in the given namespace. +func (c *Command) doesKubernetesSecretExist() (bool, error) { + _, err := c.k8sClient.CoreV1().Secrets(c.flagNamespace).Get(c.ctx, c.flagSecretName, metav1.GetOptions{}) + + // If the secret does not exist, the error will be a NotFound error. + if err != nil && apierrors.IsNotFound(err) { + return false, nil + } + + // If the error is not a NotFound error, return the error. + if err != nil && !apierrors.IsNotFound(err) { + return false, fmt.Errorf("failed to get Kubernetes secret: %v", err) + } + + // The secret exists. + return true, nil +} + +// generateGossipSecret generates a random 32 byte secret returned as a base64 encoded string. +func generateGossipSecret() (string, error) { + // This code was copied from Consul's Keygen command: + // https://github.com/hashicorp/consul/blob/d652cc86e3d0322102c2b5e9026c6a60f36c17a5/command/keygen/keygen.go + + key := make([]byte, 32) + n, err := rand.Reader.Read(key) + + if err != nil { + return "", fmt.Errorf("error reading random data: %s", err) + } + if n != 32 { + return "", fmt.Errorf("couldn't read enough entropy") + } + + return base64.StdEncoding.EncodeToString(key), nil +} + +const synopsis = "Generate and store a secret for gossip encryption." +const help = ` +Usage: consul-k8s-control-plane gossip-encryption-autogenerate [options] + + Bootstraps the installation with a secret for gossip encryption. +` diff --git a/control-plane/subcommand/gossip-encryption-autogenerate/command_test.go b/control-plane/subcommand/gossip-encryption-autogenerate/command_test.go new file mode 100644 index 0000000000..91d7101232 --- /dev/null +++ b/control-plane/subcommand/gossip-encryption-autogenerate/command_test.go @@ -0,0 +1,103 @@ +package gossipencryptionautogenerate + +import ( + "context" + "encoding/base64" + "fmt" + "testing" + + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestRun_FlagValidation(t *testing.T) { + t.Parallel() + cases := []struct { + flags []string + expErr string + }{ + { + flags: []string{}, + expErr: "-namespace must be set", + }, + { + flags: []string{"-namespace", "default"}, + expErr: "-secret-name must be set", + }, + { + flags: []string{"-namespace", "default", "-secret-name", "my-secret", "-log-level", "oak"}, + expErr: "unknown log level", + }, + } + + for _, c := range cases { + t.Run(c.expErr, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := Command{ + UI: ui, + } + code := cmd.Run(c.flags) + require.Equal(t, 1, code) + require.Contains(t, ui.ErrorWriter.String(), c.expErr) + }) + } +} + +func TestRun_EarlyTerminationWithSuccessCodeIfSecretExists(t *testing.T) { + namespace := "default" + secretName := "my-secret" + secretKey := "my-secret-key" + + ui := cli.NewMockUi() + k8s := fake.NewSimpleClientset() + + cmd := Command{UI: ui, k8sClient: k8s} + + // Create a secret. + secret := v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + secretKey: []byte(secretKey), + }, + } + _, err := k8s.CoreV1().Secrets(namespace).Create(context.Background(), &secret, metav1.CreateOptions{}) + require.NoError(t, err) + + // Run the command. + flags := []string{"-namespace", namespace, "-secret-name", secretName, "-secret-key", secretKey} + code := cmd.Run(flags) + + require.Equal(t, 0, code) + require.Contains(t, ui.OutputWriter.String(), fmt.Sprintf("A Kubernetes secret with the name `%s` already exists.", secretName)) +} + +func TestRun_SecretIsGeneratedIfNoneExists(t *testing.T) { + namespace := "default" + secretName := "my-secret" + secretKey := "my-secret-key" + + ui := cli.NewMockUi() + k8s := fake.NewSimpleClientset() + + cmd := Command{UI: ui, k8sClient: k8s} + + // Run the command. + flags := []string{"-namespace", namespace, "-secret-name", secretName, "-secret-key", secretKey} + code := cmd.Run(flags) + + require.Equal(t, 0, code) + require.Contains(t, ui.OutputWriter.String(), fmt.Sprintf("Successfully created Kubernetes secret `%s` in namespace `%s`.", secretName, namespace)) + + // Check the secret was created. + secret, err := k8s.CoreV1().Secrets(namespace).Get(context.Background(), secretName, metav1.GetOptions{}) + require.NoError(t, err) + gossipSecret, err := base64.StdEncoding.DecodeString(string(secret.Data[secretKey])) + require.NoError(t, err) + require.Len(t, gossipSecret, 32) +}