Add config CLI command and encrypt/decrypt sub-commands (#77)

This commit is contained in:
Jeff Malnick authored and GitHub committed 2020-06-24 16:08:13 -04:00
1 parent 8de5371499
commit 8afcbd4c8e
12 files changed
+509 -49

No files matched your search

+34
View File
@@ -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) {
+1 -1
View File
@@ -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)
}
+24 -38
View File
@@ -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.
+42
View File
@@ -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 <subcommand> [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
}
@@ -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
}
@@ -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)))
}
})
}
}
@@ -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)}}"
}
@@ -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=)}}"
}
+21 -2
View File
@@ -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
+21 -2
View File
@@ -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
+22 -6
View File
@@ -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)
}
+49
View File
@@ -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))
}