diff --git a/api/connect_intention.go b/api/connect_intention.go index c28c55de13..e43506a8af 100644 --- a/api/connect_intention.go +++ b/api/connect_intention.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "time" ) @@ -50,6 +51,21 @@ type Intention struct { ModifyIndex uint64 } +// String returns human-friendly output describing ths intention. +func (i *Intention) String() string { + source := i.SourceName + if i.SourceNS != "" { + source = i.SourceNS + "/" + source + } + + dest := i.DestinationName + if i.DestinationNS != "" { + dest = i.DestinationNS + "/" + dest + } + + return fmt.Sprintf("%s => %s (%s)", source, dest, i.Action) +} + // IntentionAction is the action that the intention represents. This // can be "allow" or "deny" to whitelist or blacklist intentions. type IntentionAction string diff --git a/command/commands_oss.go b/command/commands_oss.go index c1e3e794ab..79636c5988 100644 --- a/command/commands_oss.go +++ b/command/commands_oss.go @@ -12,6 +12,8 @@ import ( "github.com/hashicorp/consul/command/exec" "github.com/hashicorp/consul/command/forceleave" "github.com/hashicorp/consul/command/info" + "github.com/hashicorp/consul/command/intention" + ixncreate "github.com/hashicorp/consul/command/intention/create" "github.com/hashicorp/consul/command/join" "github.com/hashicorp/consul/command/keygen" "github.com/hashicorp/consul/command/keyring" @@ -66,6 +68,8 @@ func init() { Register("exec", func(ui cli.Ui) (cli.Command, error) { return exec.New(ui, MakeShutdownCh()), nil }) Register("force-leave", func(ui cli.Ui) (cli.Command, error) { return forceleave.New(ui), nil }) Register("info", func(ui cli.Ui) (cli.Command, error) { return info.New(ui), nil }) + Register("intention", func(ui cli.Ui) (cli.Command, error) { return intention.New(), nil }) + Register("intention create", func(ui cli.Ui) (cli.Command, error) { return ixncreate.New(), nil }) Register("join", func(ui cli.Ui) (cli.Command, error) { return join.New(ui), nil }) Register("keygen", func(ui cli.Ui) (cli.Command, error) { return keygen.New(ui), nil }) Register("keyring", func(ui cli.Ui) (cli.Command, error) { return keyring.New(ui), nil }) diff --git a/command/intention/create/create.go b/command/intention/create/create.go new file mode 100644 index 0000000000..b847117a15 --- /dev/null +++ b/command/intention/create/create.go @@ -0,0 +1,193 @@ +package create + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/command/flags" + "github.com/mitchellh/cli" +) + +func New(ui cli.Ui) *cmd { + c := &cmd{UI: ui} + c.init() + return c +} + +type cmd struct { + UI cli.Ui + flags *flag.FlagSet + http *flags.HTTPFlags + help string + + // flags + flagAllow bool + flagDeny bool + flagFile bool + flagReplace bool + flagMeta map[string]string + + // testStdin is the input for testing. + testStdin io.Reader +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.BoolVar(&c.flagAllow, "allow", false, + "Create an intention that allows when matched.") + c.flags.BoolVar(&c.flagDeny, "deny", false, + "Create an intention that denies when matched.") + c.flags.BoolVar(&c.flagFile, "file", false, + "Read intention data from one or more files.") + c.flags.BoolVar(&c.flagReplace, "replace", false, + "Replace matching intentions.") + c.flags.Var((*flags.FlagMapValue)(&c.flagMeta), "meta", + "Metadata to set on the intention, formatted as key=value. This flag "+ + "may be specified multiple times to set multiple meta fields.") + + c.http = &flags.HTTPFlags{} + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + c.help = flags.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + if err := c.flags.Parse(args); err != nil { + return 1 + } + + // Default to allow + if !c.flagAllow && !c.flagDeny { + c.flagAllow = true + } + + // If both are specified it is an error + if c.flagAllow && c.flagDeny { + c.UI.Error("Only one of -allow or -deny may be specified.") + return 1 + } + + // Check for arg validation + args = c.flags.Args() + ixns, err := c.ixnsFromArgs(args) + if err != nil { + c.UI.Error(fmt.Sprintf("Error: %s", err)) + return 1 + } + + // Create and test the HTTP client + client, err := c.http.APIClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err)) + return 1 + } + + // Go through and create each intention + for _, ixn := range ixns { + _, _, err := client.Connect().IntentionCreate(ixn, nil) + if err != nil { + c.UI.Error(fmt.Sprintf("Error creating intention %q: %s", ixn, err)) + return 1 + } + + c.UI.Output(fmt.Sprintf("Created: %s", ixn)) + } + + return 0 +} + +// ixnsFromArgs returns the set of intentions to create based on the arguments +// given and the flags set. This will call ixnsFromFiles if the -file flag +// was set. +func (c *cmd) ixnsFromArgs(args []string) ([]*api.Intention, error) { + // If we're in file mode, load from files + if c.flagFile { + return c.ixnsFromFiles(args) + } + + // From args we require exactly two + if len(args) != 2 { + return nil, fmt.Errorf("Must specify two arguments: source and destination") + } + + return []*api.Intention{&api.Intention{ + SourceName: args[0], + DestinationName: args[1], + SourceType: api.IntentionSourceConsul, + Action: c.ixnAction(), + Meta: c.flagMeta, + }}, nil +} + +func (c *cmd) ixnsFromFiles(args []string) ([]*api.Intention, error) { + var result []*api.Intention + for _, path := range args { + f, err := os.Open(path) + if err != nil { + return nil, err + } + + var ixn api.Intention + err = json.NewDecoder(f).Decode(&ixn) + f.Close() + if err != nil { + return nil, err + } + + result = append(result, &ixn) + } + + return result, nil +} + +// ixnAction returns the api.IntentionAction based on the flag set. +func (c *cmd) ixnAction() api.IntentionAction { + if c.flagAllow { + return api.IntentionActionAllow + } + + return api.IntentionActionDeny +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return c.help +} + +const synopsis = "Create intentions for service connections." +const help = ` +Usage: consul intention create [options] SRC DST +Usage: consul intention create [options] -file FILE... + + Create one or more intentions. The data can be specified as a single + source and destination pair or via a set of files when the "-file" flag + is specified. + + $ consul intention create web db + + To consume data from a set of files: + + $ consul intention create -file one.json two.json + + When specifying the "-file" flag, "-" may be used once to read from stdin: + + $ echo "{ ... }" | consul intention create -file - + + An "allow" intention is created by default (whitelist). To create a + "deny" intention, the "-deny" flag should be specified. + + If a conflicting intention is found, creation will fail. To replace any + conflicting intentions, specify the "-replace" flag. This will replace any + conflicting intentions with the intention specified in this command. + Metadata and any other fields of the previous intention will not be + preserved. + + Additional flags and more advanced use cases are detailed below. +` diff --git a/command/intention/create/create_test.go b/command/intention/create/create_test.go new file mode 100644 index 0000000000..963a3edc6c --- /dev/null +++ b/command/intention/create/create_test.go @@ -0,0 +1,188 @@ +package create + +import ( + "os" + "strings" + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestCommand_noTabs(t *testing.T) { + t.Parallel() + if strings.ContainsRune(New(nil).Help(), '\t') { + t.Fatal("help has tabs") + } +} + +func TestCommand_Validation(t *testing.T) { + t.Parallel() + + ui := cli.NewMockUi() + c := New(ui) + + cases := map[string]struct { + args []string + output string + }{ + "-allow and -deny": { + []string{"-allow", "-deny", "foo", "bar"}, + "one of -allow", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + require := require.New(t) + + c.init() + + // Ensure our buffer is always clear + if ui.ErrorWriter != nil { + ui.ErrorWriter.Reset() + } + if ui.OutputWriter != nil { + ui.OutputWriter.Reset() + } + + require.Equal(1, c.Run(tc.args)) + output := ui.ErrorWriter.String() + require.Contains(output, tc.output) + }) + } +} + +func TestCommand(t *testing.T) { + t.Parallel() + + require := require.New(t) + a := agent.NewTestAgent(t.Name(), ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "foo", "bar", + } + require.Equal(0, c.Run(args), ui.ErrorWriter.String()) + + ixns, _, err := client.Connect().Intentions(nil) + require.NoError(err) + require.Len(ixns, 1) + require.Equal("foo", ixns[0].SourceName) + require.Equal("bar", ixns[0].DestinationName) + require.Equal(api.IntentionActionAllow, ixns[0].Action) +} + +func TestCommand_deny(t *testing.T) { + t.Parallel() + + require := require.New(t) + a := agent.NewTestAgent(t.Name(), ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-deny", + "foo", "bar", + } + require.Equal(0, c.Run(args), ui.ErrorWriter.String()) + + ixns, _, err := client.Connect().Intentions(nil) + require.NoError(err) + require.Len(ixns, 1) + require.Equal("foo", ixns[0].SourceName) + require.Equal("bar", ixns[0].DestinationName) + require.Equal(api.IntentionActionDeny, ixns[0].Action) +} + +func TestCommand_meta(t *testing.T) { + t.Parallel() + + require := require.New(t) + a := agent.NewTestAgent(t.Name(), ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-meta", "hello=world", + "foo", "bar", + } + require.Equal(0, c.Run(args), ui.ErrorWriter.String()) + + ixns, _, err := client.Connect().Intentions(nil) + require.NoError(err) + require.Len(ixns, 1) + require.Equal("foo", ixns[0].SourceName) + require.Equal("bar", ixns[0].DestinationName) + require.Equal(map[string]string{"hello": "world"}, ixns[0].Meta) +} + +func TestCommand_File(t *testing.T) { + t.Parallel() + + require := require.New(t) + a := agent.NewTestAgent(t.Name(), ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + contents := `{ "SourceName": "foo", "DestinationName": "bar", "Action": "allow" }` + f := testutil.TempFile(t, "intention-create-command-file") + defer os.Remove(f.Name()) + if _, err := f.WriteString(contents); err != nil { + t.Fatalf("err: %#v", err) + } + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-file", + f.Name(), + } + + require.Equal(0, c.Run(args), ui.ErrorWriter.String()) + + ixns, _, err := client.Connect().Intentions(nil) + require.NoError(err) + require.Len(ixns, 1) + require.Equal("foo", ixns[0].SourceName) + require.Equal("bar", ixns[0].DestinationName) + require.Equal(api.IntentionActionAllow, ixns[0].Action) +} + +func TestCommand_FileNoExist(t *testing.T) { + t.Parallel() + + require := require.New(t) + a := agent.NewTestAgent(t.Name(), ``) + defer a.Shutdown() + + ui := cli.NewMockUi() + c := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-file", + "shouldnotexist.txt", + } + + require.Equal(1, c.Run(args), ui.ErrorWriter.String()) + require.Contains(ui.ErrorWriter.String(), "no such file") +} diff --git a/command/intention/intention.go b/command/intention/intention.go new file mode 100644 index 0000000000..767e3ff1b7 --- /dev/null +++ b/command/intention/intention.go @@ -0,0 +1,48 @@ +package intention + +import ( + "github.com/hashicorp/consul/command/flags" + "github.com/mitchellh/cli" +) + +func New() *cmd { + return &cmd{} +} + +type cmd struct{} + +func (c *cmd) Run(args []string) int { + return cli.RunResultHelp +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(help, nil) +} + +const synopsis = "Interact with Connect service intentions" +const help = ` +Usage: consul intention [options] [args] + + This command has subcommands for interacting with intentions. Intentions + are the permissions for what services are allowed to communicate via + Connect. Here are some simple examples, and more detailed examples are + available in the subcommands or the documentation. + + Create an intention to allow "web" to talk to "db": + + $ consul intention create web db + + Test whether a "web" is allowed to connect to "db": + + $ consul intention check web db + + Find all intentions for communicating to the "db" service: + + $ consul intention match db + + For more examples, ask for subcommand help or view the documentation. +` diff --git a/command/intention/intention_test.go b/command/intention/intention_test.go new file mode 100644 index 0000000000..e697f537fd --- /dev/null +++ b/command/intention/intention_test.go @@ -0,0 +1,13 @@ +package intention + +import ( + "strings" + "testing" +) + +func TestCommand_noTabs(t *testing.T) { + t.Parallel() + if strings.ContainsRune(New().Help(), '\t') { + t.Fatal("help has tabs") + } +}