diff --git a/internal/cmd/base/base.go b/internal/cmd/base/base.go index d43791aeb..2a97c1863 100644 --- a/internal/cmd/base/base.go +++ b/internal/cmd/base/base.go @@ -8,9 +8,11 @@ import ( "io" "io/ioutil" "os" + "os/signal" "regexp" "strings" "sync" + "syscall" "github.com/hashicorp/watchtower/api" "github.com/mitchellh/cli" @@ -56,6 +58,38 @@ type Command struct { client *api.Client } +// New returns a new instance of a base.Command type +func New(ui cli.Ui) *Command { + ctx, cancel := context.WithCancel(context.Background()) + ret := &Command{ + UI: ui, + ShutdownCh: MakeShutdownCh(), + Context: ctx, + } + + go func() { + <-ret.ShutdownCh + cancel() + }() + + return ret +} + +// MakeShutdownCh returns a channel that can be used for shutdown +// notifications for commands. This channel will send a message for every +// SIGINT or SIGTERM received. +func MakeShutdownCh() chan struct{} { + resultCh := make(chan struct{}) + + shutdownCh := make(chan os.Signal, 4) + signal.Notify(shutdownCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-shutdownCh + close(resultCh) + }() + return resultCh +} + // Client returns the HTTP API client. The client is cached on the command to // save performance on future calls. func (c *Command) Client() (*api.Client, error) { diff --git a/internal/cmd/base/servers.go b/internal/cmd/base/servers.go index a24a8bae8..4c23fa4ad 100644 --- a/internal/cmd/base/servers.go +++ b/internal/cmd/base/servers.go @@ -314,7 +314,7 @@ func (b *Server) SetupKMSes(ui cli.Ui, config *configutil.SharedConfig, purposes switch purpose { case "": return errors.New("KMS block missing 'purpose'") - case "controller", "worker-auth": + case "controller", "worker-auth", "config": default: return fmt.Errorf("Unknown KMS purpose %q", kms.Purpose) } diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index ef2dfcac4..f62b635ec 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -1,17 +1,18 @@ package cmd import ( - "context" "os" "os/signal" "syscall" "github.com/hashicorp/watchtower/internal/cmd/base" + "github.com/hashicorp/watchtower/internal/cmd/commands/config" "github.com/hashicorp/watchtower/internal/cmd/commands/controller" "github.com/hashicorp/watchtower/internal/cmd/commands/dev" "github.com/hashicorp/watchtower/internal/cmd/commands/hosts" "github.com/hashicorp/watchtower/internal/cmd/commands/scopes" "github.com/hashicorp/watchtower/internal/cmd/commands/worker" + "github.com/mitchellh/cli" ) @@ -19,28 +20,12 @@ import ( var Commands map[string]cli.CommandFactory func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { - getBaseCommand := func() *base.Command { - ctx, cancel := context.WithCancel(context.Background()) - ret := &base.Command{ - UI: ui, - ShutdownCh: MakeShutdownCh(), - Context: ctx, - } - - go func() { - <-ret.ShutdownCh - cancel() - }() - - return ret - } - Commands = map[string]cli.CommandFactory{ "controller": func() (cli.Command, error) { return &controller.Command{ Server: base.NewServer(&base.Command{ UI: serverCmdUi, - ShutdownCh: MakeShutdownCh(), + ShutdownCh: base.MakeShutdownCh(), }), SighupCh: MakeSighupCh(), SigUSR2Ch: MakeSigUSR2Ch(), @@ -50,7 +35,7 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { return &worker.Command{ Server: base.NewServer(&base.Command{ UI: serverCmdUi, - ShutdownCh: MakeShutdownCh(), + ShutdownCh: base.MakeShutdownCh(), }), SighupCh: MakeSighupCh(), SigUSR2Ch: MakeSigUSR2Ch(), @@ -60,7 +45,7 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { return &dev.Command{ Server: base.NewServer(&base.Command{ UI: serverCmdUi, - ShutdownCh: MakeShutdownCh(), + ShutdownCh: base.MakeShutdownCh(), }), SighupCh: MakeSighupCh(), SigUSR2Ch: MakeSigUSR2Ch(), @@ -68,37 +53,38 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { }, "hosts create": func() (cli.Command, error) { return &hosts.CreateCommand{ - Command: getBaseCommand(), + Command: base.New(ui), }, nil }, "projects create": func() (cli.Command, error) { return &scopes.CreateProjectCommand{ - Command: getBaseCommand(), + Command: base.New(ui), }, nil }, "projects read": func() (cli.Command, error) { return &scopes.ReadProjectCommand{ - Command: getBaseCommand(), + Command: base.New(ui), + }, nil + }, + "config": func() (cli.Command, error) { + return &config.Command{ + Command: base.New(ui), + }, nil + }, + "config encrypt": func() (cli.Command, error) { + return &config.EncryptDecryptCommand{ + Command: base.New(ui), + Encrypt: true, + }, nil + }, + "config decrypt": func() (cli.Command, error) { + return &config.EncryptDecryptCommand{ + Command: base.New(ui), }, nil }, } } -// MakeShutdownCh returns a channel that can be used for shutdown -// notifications for commands. This channel will send a message for every -// SIGINT or SIGTERM received. -func MakeShutdownCh() chan struct{} { - resultCh := make(chan struct{}) - - shutdownCh := make(chan os.Signal, 4) - signal.Notify(shutdownCh, os.Interrupt, syscall.SIGTERM) - go func() { - <-shutdownCh - close(resultCh) - }() - return resultCh -} - // MakeSighupCh returns a channel that can be used for SIGHUP // reloading. This channel will send a message for every // SIGHUP received. diff --git a/internal/cmd/commands/config/config.go b/internal/cmd/commands/config/config.go new file mode 100644 index 000000000..0412a96f0 --- /dev/null +++ b/internal/cmd/commands/config/config.go @@ -0,0 +1,42 @@ +package config + +import ( + "strings" + + "github.com/hashicorp/watchtower/internal/cmd/base" + "github.com/mitchellh/cli" +) + +var _ cli.Command = (*Command)(nil) + +type Command struct { + *base.Command +} + +func (c *Command) Synopsis() string { + return "Manage sensitive values in Watchtower's configuration files" +} + +func (c *Command) Help() string { + helpText := ` +Usage: watchtower config [options] [args] + + This command groups subcommands for operators interacting with Watchtower's + config files. Here are a few examples of config commands: + + Encrypt sensitive values in a config file: + + $ watchtower config encrypt config.hcl + + Decrypt sensitive values in a config file: + + $ watchtower config decrypt config.hcl + + Please see the individual subcommand help for detailed usage information.` + + return strings.TrimSpace(helpText) +} + +func (c *Command) Run(args []string) int { + return cli.RunResultHelp +} diff --git a/internal/cmd/commands/config/encryptdecrypt.go b/internal/cmd/commands/config/encryptdecrypt.go new file mode 100644 index 000000000..bd8057985 --- /dev/null +++ b/internal/cmd/commands/config/encryptdecrypt.go @@ -0,0 +1,186 @@ +package config + +import ( + "fmt" + "io/ioutil" + "os" + "strings" + + "github.com/hashicorp/vault/internalshared/configutil" + "github.com/hashicorp/vault/sdk/helper/strutil" + "github.com/hashicorp/watchtower/internal/cmd/base" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +var _ cli.Command = (*EncryptDecryptCommand)(nil) +var _ cli.CommandAutocomplete = (*EncryptDecryptCommand)(nil) + +type EncryptDecryptCommand struct { + *base.Command + Encrypt bool + + flagOverwrite bool + flagStrip bool +} + +func (c *EncryptDecryptCommand) Synopsis() string { + dir := "Decrypts" + if c.Encrypt { + dir = "Encrypts" + } + return fmt.Sprintf("%s sensitive values in Vault's configuration file", dir) +} + +func (c *EncryptDecryptCommand) Help() string { + subCmd := "Decrypt" + if c.Encrypt { + subCmd = "Encrypt" + } + helpText := ` +Usage: watchtower config %s [options] [args] + + %s sensitive values in a Watchtower's configuration file. These values must be marked + with {{%s()}} as appropriate. This can only be used with string parameters, and + the markers must be inside the quote marks delimiting the string; as an example: + + foo = "{{encrypt(bar)}}" + + By default this will print the new configuration out. To overwrite into the same + file use the -overwrite flag. + + $ watchtower config %s -overwrite config.hcl + ` + helpText = fmt.Sprintf(helpText, strings.ToLower(subCmd), subCmd, strings.ToLower(subCmd), strings.ToLower(subCmd)) + + return strings.TrimSpace(helpText) +} + +func (c *EncryptDecryptCommand) Flags() *base.FlagSets { + set := c.FlagSet(0) + + f := set.NewFlagSet("Command Options") + + f.BoolVar(&base.BoolVar{ + Name: "overwrite", + Target: &c.flagOverwrite, + Usage: "Overwrite the existing file.", + }) + + f.BoolVar(&base.BoolVar{ + Name: "strip", + Target: &c.flagStrip, + Usage: "Strip the declarations from the file afterwards.", + }) + + return set +} + +func (c *EncryptDecryptCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictAnything +} + +func (c *EncryptDecryptCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *EncryptDecryptCommand) Run(args []string) (ret int) { + op := "decrypt" + if c.Encrypt { + op = "encrypt" + } + + f := c.Flags() + if err := f.Parse(args); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + path := "" + args = f.Args() + switch len(args) { + case 1: + path = strings.TrimSpace(args[0]) + default: + c.UI.Error(fmt.Sprintf("Incorrect arguments (expected 1, got %d)", len(args))) + return 1 + } + + if path == "" { + c.UI.Error("A configuration file must be specified") + return 1 + } + + kmses, err := configutil.LoadConfigKMSes(path) + if err != nil { + c.UI.Error(fmt.Errorf("Error loading configuration from %s: %w", path, err).Error()) + return 1 + } + + var kms *configutil.KMS + for _, v := range kmses { + if strutil.StrListContains(v.Purpose, "config") { + if kms != nil { + c.UI.Error("Only one kms block marked for \"config\" purpose is allowed") + return 1 + } + kms = v + } + } + if kms == nil { + c.UI.Error("No kms block with \"config\" purpose defined in the configuration file") + return 1 + } + + d, err := ioutil.ReadFile(path) + if err != nil { + c.UI.Error(fmt.Errorf("Error reading config file: %w", err).Error()) + return 1 + } + + raw := string(d) + + wrapper, err := configutil.ConfigureWrapper(kms, nil, nil, nil) + if err != nil { + c.UI.Error(fmt.Errorf("Error creating kms: %w", err).Error()) + return 1 + } + + wrapper.Init(c.Context) + defer wrapper.Finalize(c.Context) + + raw, err = configutil.EncryptDecrypt(raw, !c.Encrypt, c.flagStrip, wrapper) + if err != nil { + c.UI.Error(fmt.Errorf("Error %sing via kms: %w", op, err).Error()) + return 1 + } + + if !c.flagOverwrite { + c.UI.Output(raw) + return 0 + } + + file, err := os.Create(path) + if err != nil { + c.UI.Error(fmt.Errorf("Error opening file for writing: %w", err).Error()) + return 1 + } + + defer func() { + if err := file.Close(); err != nil { + c.UI.Error(fmt.Errorf("Error closing file after writing: %w", err).Error()) + ret = 1 + } + }() + + n, err := file.WriteString(raw) + if err != nil { + c.UI.Error(fmt.Errorf("Error writing to file: %w", err).Error()) + return 1 + } + if n != len(raw) { + c.UI.Error(fmt.Sprintf("Wrong number of bytes written to file, expected %d, wrote %d", len(raw), n)) + } + + return 0 +} diff --git a/internal/cmd/commands/config/encryptdecrypt_test.go b/internal/cmd/commands/config/encryptdecrypt_test.go new file mode 100644 index 000000000..a4c4b944b --- /dev/null +++ b/internal/cmd/commands/config/encryptdecrypt_test.go @@ -0,0 +1,75 @@ +package config + +import ( + "bufio" + "bytes" + "fmt" + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/hashicorp/watchtower/internal/cmd/base" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/assert" +) + +const ( + configEncryptPath = "./fixtures/configEncrypt.hcl" + configDecryptPath = "./fixtures/configDecrypt.hcl" +) + +func TestEncryptDecrypt(t *testing.T) { + + cases := []struct { + encrypt bool + config string + exp string + }{ + { + encrypt: true, + config: configEncryptPath, + exp: "", + }, + { + encrypt: false, + config: configDecryptPath, + exp: configEncryptPath, + }, + } + + for _, c := range cases { + t.Run(fmt.Sprintf("Test encrypt %v", c.encrypt), func(t *testing.T) { + + var b bytes.Buffer + + ui := &cli.BasicUi{ + Reader: bufio.NewReader(os.Stdin), + Writer: &b, + ErrorWriter: &b, + } + + cmd := &EncryptDecryptCommand{ + Command: base.New(ui), + Encrypt: c.encrypt} + + if err := cmd.Run([]string{c.config}); err != 0 { + assert.Equal(t, err, 0) + } + + got := b.String() + assert.Greater(t, len(got), 0) + fmt.Printf("%s\n", got) + + // If it's encrypting, then we can assume that it did the right thing since + // there are many tests on the underlying codebase for that. If it's not + // encrypting, compare it to the cleartext to verify because we can. + if !c.encrypt { + expected, err := ioutil.ReadFile(c.exp) + assert.NoError(t, err) + + assert.Equal(t, strings.TrimSpace(string(expected)), strings.TrimSpace(string(got))) + } + }) + } +} diff --git a/internal/cmd/commands/config/fixtures/configDecrypt.hcl b/internal/cmd/commands/config/fixtures/configDecrypt.hcl new file mode 100644 index 000000000..9305a2f50 --- /dev/null +++ b/internal/cmd/commands/config/fixtures/configDecrypt.hcl @@ -0,0 +1,17 @@ +kms "aead" { + purpose = "config" + aead_type = "aes-gcm" + key = "c964AJj8VW8w4hKz/Jd8MvuLt0kkcjVuFqMiMvTvvN8=" +} + +kms "aead" { + purpose = "controller" + aead_type = "aes-gcm" + key ="{{decrypt(CkgsDUoQ7B5JhE1ye2yVAm3Ss_KNbLQgBhKtibtGSvpGOrwQ3zRTF_qoHPHX-xuqLyZ9zsb3cMo6OpphKOj7AeTn1_iiqZ_VaCsqAA)}}" +} + +kms "aead" { + purpose = "worker" + aead_type = "aes-gcm" + key ="{{decrypt(CkiRTINwX19TnC3AB-zx5E133TXI9KzBWb8TxfVDrPb9m3Yfm9K99OkuJgRTj1rjmeMF-Kpl-0oouEc8_mNk6oPIqD8nUNvH3FYqAA)}}" +} diff --git a/internal/cmd/commands/config/fixtures/configEncrypt.hcl b/internal/cmd/commands/config/fixtures/configEncrypt.hcl new file mode 100644 index 000000000..5b97b21e8 --- /dev/null +++ b/internal/cmd/commands/config/fixtures/configEncrypt.hcl @@ -0,0 +1,17 @@ +kms "aead" { + purpose = "config" + aead_type = "aes-gcm" + key = "c964AJj8VW8w4hKz/Jd8MvuLt0kkcjVuFqMiMvTvvN8=" +} + +kms "aead" { + purpose = "controller" + aead_type = "aes-gcm" + key ="{{encrypt(eb78KqCwowELYnkOOko/XYz01q1ax3g76J1vCAvt5dQ=)}}" +} + +kms "aead" { + purpose = "worker" + aead_type = "aes-gcm" + key ="{{encrypt(aA1hxJo0JUAqcIATx/r0QTjAGD/btCPechEsukI2bt0=)}}" +} diff --git a/internal/cmd/commands/controller/controller.go b/internal/cmd/commands/controller/controller.go index 925c3f28e..e1817e063 100644 --- a/internal/cmd/commands/controller/controller.go +++ b/internal/cmd/commands/controller/controller.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-multierror" + "github.com/hashicorp/vault/internalshared/configutil" "github.com/hashicorp/vault/sdk/helper/mlock" "github.com/hashicorp/vault/sdk/helper/strutil" "github.com/hashicorp/watchtower/globals" @@ -36,6 +37,8 @@ type Command struct { Config *config.Config controller *controller.Controller + configKMS *configutil.KMS + flagConfig string flagLogLevel string flagLogFormat string @@ -288,6 +291,22 @@ func (c *Command) ParseFlagsAndConfig(args []string) int { return 1 } + // preload the KMS for encrypting/decrypting the config parameters + kmss, err := configutil.LoadConfigKMSes(c.flagConfig) + if err != nil { + c.UI.Error("error loading KMS config: " + err.Error()) + return 1 + } + + for _, kms := range kmss { + for _, purpose := range kms.Purpose { + if purpose == "config" { + c.configKMS = kms + break + } + } + } + // Validation if !c.flagDev { switch { @@ -301,7 +320,7 @@ func (c *Command) ParseFlagsAndConfig(args []string) int { c.flagDevAdminPassword = "" } - c.Config, err = config.LoadFile(c.flagConfig) + c.Config, err = config.LoadFile(c.flagConfig, c.configKMS) if err != nil { c.UI.Error("Error parsing config: " + err.Error()) return 1 @@ -410,7 +429,7 @@ func (c *Command) WaitForInterrupt() int { goto RUNRELOADFUNCS } - newConf, err = config.LoadFile(c.flagConfig) + newConf, err = config.LoadFile(c.flagConfig, c.configKMS) if err != nil { c.Logger.Error("could not reload config", "path", c.flagConfig, "error", err) goto RUNRELOADFUNCS diff --git a/internal/cmd/commands/worker/worker.go b/internal/cmd/commands/worker/worker.go index 1a31555dc..222d95f91 100644 --- a/internal/cmd/commands/worker/worker.go +++ b/internal/cmd/commands/worker/worker.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-multierror" + "github.com/hashicorp/vault/internalshared/configutil" "github.com/hashicorp/vault/sdk/helper/mlock" "github.com/hashicorp/watchtower/internal/cmd/base" "github.com/hashicorp/watchtower/internal/cmd/config" @@ -32,6 +33,8 @@ type Command struct { Config *config.Config worker *worker.Worker + configKMS *configutil.KMS + flagConfig string flagLogLevel string flagLogFormat string @@ -203,13 +206,29 @@ func (c *Command) ParseFlagsAndConfig(args []string) int { return 1 } + // preload the KMS for encrypting/decrypting the config parameters + kmss, err := configutil.LoadConfigKMSes(c.flagConfig) + if err != nil { + c.UI.Error("error loading KMS config: " + err.Error()) + return 1 + } + + for _, kms := range kmss { + for _, purpose := range kms.Purpose { + if purpose == "config" { + c.configKMS = kms + break + } + } + } + // Validation if !c.flagDev { if len(c.flagConfig) == 0 { c.UI.Error("Must supply a config file with -config") return 1 } - c.Config, err = config.LoadFile(c.flagConfig) + c.Config, err = config.LoadFile(c.flagConfig, c.configKMS) if err != nil { c.UI.Error("Error parsing config: " + err.Error()) return 1 @@ -288,7 +307,7 @@ func (c *Command) WaitForInterrupt() int { goto RUNRELOADFUNCS } - newConf, err = config.LoadFile(c.flagConfig) + newConf, err = config.LoadFile(c.flagConfig, c.configKMS) if err != nil { c.Logger.Error("could not reload config", "path", c.flagConfig, "error", err) goto RUNRELOADFUNCS diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 01d915293..1dbdfe0c0 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "context" "crypto/rand" "encoding/base64" "fmt" @@ -113,19 +114,22 @@ func New() *Config { } // LoadFile loads the configuration from the given file. -func LoadFile(path string) (*Config, error) { - // Read the file +func LoadFile(path string, kms *configutil.KMS) (*Config, error) { d, err := ioutil.ReadFile(path) if err != nil { return nil, err } - conf, err := Parse(string(d)) - if err != nil { - return nil, err + raw := string(d) + + if kms != nil { + raw, err = configDecrypt(raw, kms) + if err != nil { + return nil, err + } } - return conf, nil + return Parse(raw) } func Parse(d string) (*Config, error) { @@ -170,3 +174,15 @@ func (c *Config) Sanitized() map[string]interface{} { return result } + +func configDecrypt(raw string, kms *configutil.KMS) (string, error) { + wrapper, err := configutil.ConfigureWrapper(kms, nil, nil, nil) + if err != nil { + return raw, err + } + + wrapper.Init(context.Background()) + defer wrapper.Finalize(context.Background()) + + return configutil.EncryptDecrypt(raw, true, true, wrapper) +} diff --git a/internal/cmd/config/config_test.go b/internal/cmd/config/config_test.go index 02a2405b3..ca21ea0c8 100644 --- a/internal/cmd/config/config_test.go +++ b/internal/cmd/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "strings" "testing" "time" @@ -76,3 +77,51 @@ func TestDevController(t *testing.T) { assert.Equal(t, exp, actual) } + +func TestConfigDecrypt(t *testing.T) { + + const ( + clr = ` +kms "aead" { + purpose = "config" + aead_type = "aes-gcm" + key = "c964AJj8VW8w4hKz/Jd8MvuLt0kkcjVuFqMiMvTvvN8=" +} + +kms "aead" { + purpose = "controller" + aead_type = "aes-gcm" + key ="eb78KqCwowELYnkOOko/XYz01q1ax3g76J1vCAvt5dQ=" +}` + + enc = ` +kms "aead" { + purpose = "config" + aead_type = "aes-gcm" + key = "c964AJj8VW8w4hKz/Jd8MvuLt0kkcjVuFqMiMvTvvN8=" +} + +kms "aead" { + purpose = "controller" + aead_type = "aes-gcm" + key ="{{decrypt(Ckh57d4NA6nsnRKV6DiHTyfwLIakdhN8w7qdPJgo-KWnBdlEKv3NQkUFbouU0eorSGik1Qbca5xEy2NqYT9UYj_GUGo6hHz13MEqAA)}}" +}` + ) + + kmses, err := configutil.ParseKMSes(enc) + assert.NoError(t, err) + + var kms = &configutil.KMS{} + for _, k := range kmses { + for _, p := range k.Purpose { + if p == "config" { + kms = k + } + } + } + + got, err := configDecrypt(enc, kms) + assert.NoError(t, err) + + assert.Equal(t, strings.TrimSpace(got), strings.TrimSpace(clr)) +}