diff --git a/CHANGELOG.md b/CHANGELOG.md index cf2811be2e..508e8147c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,17 @@ Canonical reference for changes, improvements, and bugfixes for Boundary. This new helper command allows users to authorize sessions against Cassandra targets and automatically invoke a Cassandra client with the appropriate connection parameters and credentials. Currently only username/password credentials are automatically attached. +* cli: Added `boundary connect redis` command for connecting to Redis targets. + This new helper command allows users to authorize sessions against Redis + targets and automatically invoke a Redis client with the appropriate + connection parameters and credentials. Currently only username/password credentials are automatically attached. * ui: Improved load times for resource tables with search and filtering capabilities by replacing indexeddb for local data storage with sqlite (WASM) and OPFS ([PR](https://github.com/hashicorp/boundary-ui/pull/2984)) ### Bug fixes * ui: Fixed rendering bug where header for the Host details page rendered multiple times ([PR](https://github.com/hashicorp/boundary-ui/pull/2980)) * ui: Fixed bug where worker tags could not be removed when creating a new worker ([PR](https://github.com/hashicorp/boundary-ui/pull/2928)) + ### Deprecations/Changes * Modified parsing logic for various IP/host/address fields across Boundary. diff --git a/enos/modules/test_e2e_docker/test.sh b/enos/modules/test_e2e_docker/test.sh index 56e2e6b0ac..1c37611438 100755 --- a/enos/modules/test_e2e_docker/test.sh +++ b/enos/modules/test_e2e_docker/test.sh @@ -41,6 +41,8 @@ install_cassandra() { # Install Cassandra install_cassandra +# Install Redis +apt install redis-server -y # Create a GPG key export KEY_PW=boundary diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index 8bbecdddc6..f35594ede9 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -423,6 +423,12 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Func: "cassandra", } }), + "connect redis": wrapper.Wrap(func() wrapper.WrappableCommand { + return &connect.Command{ + Command: base.NewCommand(ui, opts...), + Func: "redis", + } + }), "connect rdp": wrapper.Wrap(func() wrapper.WrappableCommand { return &connect.Command{ Command: base.NewCommand(ui, opts...), diff --git a/internal/cmd/commands/connect/connect.go b/internal/cmd/commands/connect/connect.go index 5525cee958..ba4a99a0e4 100644 --- a/internal/cmd/commands/connect/connect.go +++ b/internal/cmd/commands/connect/connect.go @@ -84,6 +84,9 @@ type Command struct { // Cassandra cassandraFlags + // Redis + redisFlags + // RDP rdpFlags @@ -114,6 +117,8 @@ func (c *Command) Synopsis() string { return mysqlSynopsis case "cassandra": return cassandraSynopsis + case "redis": + return redisSynopsis case "rdp": return rdpSynopsis case "ssh": @@ -239,6 +244,9 @@ func (c *Command) Flags() *base.FlagSets { case "cassandra": cassandraOptions(c, set) + case "redis": + redisOptions(c, set) + case "rdp": rdpOptions(c, set) @@ -330,6 +338,8 @@ func (c *Command) Run(args []string) (retCode int) { c.flagExec = c.mysqlFlags.defaultExec() case "cassandra": c.flagExec = c.cassandraFlags.defaultExec() + case "redis": + c.flagExec = c.redisFlags.defaultExec() case "rdp": c.flagExec = c.rdpFlags.defaultExec() case "kube": @@ -714,6 +724,16 @@ func (c *Command) handleExec(clientProxy *apiproxy.ClientProxy, passthroughArgs envs = append(envs, cassandraEnvs...) creds = cassandraCreds + case "redis": + redisArgs, redisEnvs, redisCreds, redisErr := c.redisFlags.buildArgs(c, port, host, addr, creds) + if redisErr != nil { + argsErr = redisErr + break + } + args = append(args, redisArgs...) + envs = append(envs, redisEnvs...) + creds = redisCreds + case "rdp": args = append(args, c.rdpFlags.buildArgs(c, port, host, addr)...) diff --git a/internal/cmd/commands/connect/redis.go b/internal/cmd/commands/connect/redis.go new file mode 100644 index 0000000000..7a8bde6bdd --- /dev/null +++ b/internal/cmd/commands/connect/redis.go @@ -0,0 +1,82 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package connect + +import ( + "fmt" + "strings" + + "github.com/hashicorp/boundary/api/proxy" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/posener/complete" +) + +const ( + redisSynopsis = "Authorize a session against a target and invoke a redis client to connect" +) + +func redisOptions(c *Command, set *base.FlagSets) { + f := set.NewFlagSet("Redis Options") + + f.StringVar(&base.StringVar{ + Name: "style", + Target: &c.flagRedisStyle, + EnvVar: "BOUNDARY_CONNECT_REDIS_STYLE", + Completion: complete.PredictSet("redis-cli"), + Default: "redis-cli", + Usage: `Specifies how the CLI will attempt to invoke a Redis client. This will also set a suitable default for -exec if a value was not specified. Currently-understood values are "redis-cli".`, + }) + + f.StringVar(&base.StringVar{ + Name: "username", + Target: &c.flagUsername, + EnvVar: "BOUNDARY_CONNECT_USERNAME", + Completion: complete.PredictNothing, + Usage: `Specifies the username to pass through to the client. May be overridden by credentials sourced from a credential store.`, + }) +} + +type redisFlags struct { + flagRedisStyle string +} + +func (r *redisFlags) defaultExec() string { + return strings.ToLower(r.flagRedisStyle) +} + +func (r *redisFlags) buildArgs(c *Command, port, ip, _ string, creds proxy.Credentials) (args, envs []string, retCreds proxy.Credentials, retErr error) { + var username, password string + + retCreds = creds + if len(retCreds.UsernamePassword) > 0 { + // Mark credential as consumed, such that it is not printed to the user + retCreds.UsernamePassword[0].Consumed = true + + // Grab the first available username/password credential brokered + username = retCreds.UsernamePassword[0].Username + password = retCreds.UsernamePassword[0].Password + } + + switch r.flagRedisStyle { + case "redis-cli": + args = append(args, "-h", ip) + if port != "" { + args = append(args, "-p", port) + } + + switch { + case username != "": + args = append(args, "--user", username) + case c.flagUsername != "": + args = append(args, "--user", c.flagUsername, "--askpass") + } + + // Password is read by redis-cli via environment variable. The password disappears after the command exits. + if password != "" { + envs = append(envs, fmt.Sprintf("REDISCLI_AUTH=%s", password)) + } + } + + return args, envs, retCreds, retErr +} diff --git a/testing/internal/e2e/infra/docker.go b/testing/internal/e2e/infra/docker.go index f84efb8f19..8740c19546 100644 --- a/testing/internal/e2e/infra/docker.go +++ b/testing/internal/e2e/infra/docker.go @@ -35,6 +35,12 @@ type cassandraConfig struct { NetworkAlias string } +type redisConfig struct { + User string + Password string + NetworkAlias string +} + // StartBoundaryDatabase spins up a postgres database in a docker container. // Returns information about the container func StartBoundaryDatabase(t testing.TB, pool *dockertest.Pool, network *dockertest.Network, repository, tag string) *Container { @@ -421,6 +427,63 @@ func StartCassandra(t testing.TB, pool *dockertest.Pool, network *dockertest.Net } } +// StartRedis starts a Redis database in a docker container. +// Returns information about the container +func StartRedis(t testing.TB, pool *dockertest.Pool, network *dockertest.Network, repository, tag string) *Container { + t.Log("Starting Redis database...") + c, err := LoadConfig() + require.NoError(t, err) + + err = pool.Client.PullImage(docker.PullImageOptions{ + Repository: fmt.Sprintf("%s/%s", c.DockerMirror, repository), + Tag: tag, + }, docker.AuthConfiguration{}) + require.NoError(t, err) + + config := redisConfig{ + User: "e2eboundary", + Password: "e2eboundary", + NetworkAlias: "e2eredis", + } + + resource, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: fmt.Sprintf("%s/%s", c.DockerMirror, repository), + Tag: tag, + ExposedPorts: []string{"6379/tcp"}, + Name: config.NetworkAlias, + Networks: []*dockertest.Network{network}, + }) + require.NoError(t, err) + + err = pool.Retry(func() error { + cmd := exec.Command("docker", "exec", config.NetworkAlias, "redis-cli", "PING") + output, cmdErr := cmd.CombinedOutput() + if cmdErr != nil { + return fmt.Errorf("failed to connect to Redis container '%s': %v\nOutput: %s", config.NetworkAlias, cmdErr, string(output)) + } + return nil + }) + require.NoError(t, err, "Redis container did not start in time or is not healthy") + + err = setupRedisAuthAndUser(t, resource, pool, &config) + require.NoError(t, err) + + return &Container{ + Resource: resource, + UriLocalhost: fmt.Sprintf( + "redis://%s:%s@localhost:6379", + config.User, + config.Password, + ), + UriNetwork: fmt.Sprintf( + "redis://%s:%s@%s:6379", + config.User, + config.Password, + config.NetworkAlias, + ), + } +} + // setupCassandraAuthAndUser enables authentication on a Cassandra container and creates a user with permissions. func setupCassandraAuthAndUser(t testing.TB, resource *dockertest.Resource, pool *dockertest.Pool, config *cassandraConfig) error { t.Helper() @@ -479,3 +542,19 @@ func setupCassandraAuthAndUser(t testing.TB, resource *dockertest.Resource, pool } return nil } + +// setupRedisAuthAndUser configures a Redis container by creating a user with permissions. +func setupRedisAuthAndUser(t testing.TB, resource *dockertest.Resource, pool *dockertest.Pool, config *redisConfig) error { + t.Helper() + t.Log("Configuring Redis authentication and user permissions...") + + err := exec.Command( + "docker", "exec", config.NetworkAlias, "redis-cli", + "ACL", "SETUSER", config.User, "on", fmt.Sprintf(">%s", config.Password), "+@read", "+@write", "allkeys", + ).Run() + if err != nil { + return err + } + + return nil +} diff --git a/testing/internal/e2e/tests/base/target_tcp_connect_redis_test.go b/testing/internal/e2e/tests/base/target_tcp_connect_redis_test.go new file mode 100644 index 0000000000..de6956e871 --- /dev/null +++ b/testing/internal/e2e/tests/base/target_tcp_connect_redis_test.go @@ -0,0 +1,125 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package base_test + +import ( + "testing" +) + +// TestCliTcpTargetConnectRedis uses the boundary cli to connect to a target using `connect redis` +func TestCliTcpTargetConnectRedis(t *testing.T) { + t.Skip("Skipped (TODO: ICU-17634). Test fails due to issues between redis-cli and pty.") + return //nolint + + // e2e.MaybeSkipTest(t) + + // pool, err := dockertest.NewPool("") + // require.NoError(t, err) + + // ctx := context.Background() + + // network, err := pool.NetworksByName("e2e_cluster") + // require.NoError(t, err, "Failed to get e2e_cluster network") + + // c := infra.StartRedis(t, pool, &network[0], "redis", "latest") + // require.NotNil(t, c, "Redis container should not be nil") + // t.Cleanup(func() { + // if err := pool.Purge(c.Resource); err != nil { + // t.Logf("Failed to purge Redis container: %v", err) + // } + // }) + + // u, err := url.Parse(c.UriNetwork) + // t.Log(u) + // require.NoError(t, err, "Failed to parse Redis URL") + + // user, hostname, port := u.User.Username(), u.Hostname(), u.Port() + // pw, pwSet := u.User.Password() + + // t.Logf("Redis info: user=%s, host=%s, port=%s, password-set:%t", + // user, hostname, port, pwSet) + + // // Wait for Redis to be ready + // err = pool.Retry(func() error { + // out, e := exec.CommandContext(ctx, "docker", "exec", hostname, + // "redis-cli", "-h", hostname, "-p", port, "PING").CombinedOutput() + // t.Logf("Redis PING output: %s", out) + // return e + // }) + // require.NoError(t, err, "Redis container failed to start") + + // boundary.AuthenticateAdminCli(t, ctx) + + // orgId, err := boundary.CreateOrgCli(t, ctx) + // require.NoError(t, err) + // t.Cleanup(func() { + // ctx := context.Background() + // boundary.AuthenticateAdminCli(t, ctx) + // output := e2e.RunCommand(ctx, "boundary", e2e.WithArgs("scopes", "delete", "-id", orgId)) + // require.NoError(t, output.Err, string(output.Stderr)) + // }) + + // projectId, err := boundary.CreateProjectCli(t, ctx, orgId) + // require.NoError(t, err) + + // targetId, err := boundary.CreateTargetCli( + // t, + // ctx, + // projectId, + // port, + // target.WithAddress(hostname), + // ) + // require.NoError(t, err) + + // storeId, err := boundary.CreateCredentialStoreStaticCli(t, ctx, projectId) + // require.NoError(t, err) + + // credentialId, err := boundary.CreateStaticCredentialPasswordCli( + // t, + // ctx, + // storeId, + // user, + // pw, + // ) + // require.NoError(t, err) + + // err = boundary.AddBrokeredCredentialSourceToTargetCli(t, ctx, targetId, credentialId) + // require.NoError(t, err) + + // t.Logf("Attempting to connect to Redis target %s", targetId) + + // cmd := exec.CommandContext(ctx, + // "boundary", + // "connect", "redis", + // "-target-id", targetId, + // ) + + // f, err := pty.Start(cmd) + // require.NoError(t, err) + // t.Cleanup(func() { + // err := f.Close() + // require.NoError(t, err) + // }) + + // _, err = f.Write([]byte("SET e2etestkey e2etestvalue\r\n")) + // require.NoError(t, err) + // _, err = f.Write([]byte("GET e2etestkey\r\n")) + // require.NoError(t, err) + // _, err = f.Write([]byte("QUIT\r\n")) + // require.NoError(t, err) + // _, err = f.Write([]byte{4}) + // require.NoError(t, err) + + //// io.Copy will hang because not all bytes seem to be written to pty (QUIT is not recognized). + + // var buf bytes.Buffer + // _, _ = io.Copy(&buf, f) + // output := buf.String() + // t.Logf("Redis session output: %s", output) + + // require.Contains(t, output, "OK") + // require.Contains(t, output, "\"e2etestvalue\"") + + // t.Log("Successfully connected to Redis target") +} diff --git a/website/content/docs/commands/connect/index.mdx b/website/content/docs/commands/connect/index.mdx index 42ed579f9f..252130238e 100644 --- a/website/content/docs/commands/connect/index.mdx +++ b/website/content/docs/commands/connect/index.mdx @@ -45,6 +45,7 @@ Subcommands: mysql Authorize a session against a target and invoke a MySQL client to connect postgres Authorize a session against a target and invoke a Postgres client to connect rdp Authorize a session against a target and invoke an RDP client to connect + redis Authorize a session against a target and invoke a redis client to connect ssh Authorize a session against a target and invoke an SSH client to connect ``` @@ -59,6 +60,7 @@ of the subcommand in the sidebar or one of the links below: - [mysql](/boundary/docs/commands/connect/mysql) - [postgres](/boundary/docs/commands/connect/postgres) - [rdp](/boundary/docs/commands/connect/rdp) +- [redis] (/boundary/docs/commands/connect/redis) - [ssh](/boundary/docs/commands/connect/ssh) ### Command options diff --git a/website/content/docs/commands/connect/redis.mdx b/website/content/docs/commands/connect/redis.mdx new file mode 100644 index 0000000000..58b495a5b6 --- /dev/null +++ b/website/content/docs/commands/connect/redis.mdx @@ -0,0 +1,61 @@ +--- +layout: docs +page_title: connect redis - Command +description: >- + The "connect redis" command performs a target authorization or consumes an existing authorization token, and then launches a proxied redis (redis-cli) connection. +--- + +# connect redis + +Command: `boundary connect redis` +The `connect redis` command authorizes a session against a target and invokes a Redis client for the connection. +The command fills in the local address and port. + +@include 'cmd-connect-env-vars.mdx' + + +## Examples + +The following example shows how to connect to a target with the ID `ttcp_eTcZMueUYv` using a Redis helper: + +```shell-session +$ boundary connect redis -target-id=ttcp_eTcZMueUYv \ + -username=superuser +``` + +When prompted, you must enter the password for the user, "superuser": + + + + +```plaintext +Please input password: +127.0.0.1:60835> +``` + + + +## Usage + + + +```shell-session +$ boundary connect redis [options] [args] +``` + + + +@include 'cmd-connect-command-options.mdx' + +### Redis options: + +- `-style` `(string: "")` - How the CLI attempts to invoke a Redis client. +This value also sets a suitable default for `-exec`, if you did not specify a value. +The default and currently-understood value is `redis-cli`. +You can also specify how the CLI attempts to invoke a Redis client using the **BOUNDARY_CONNECT_REDIS_STYLE** environment variable. + +- `-username` `(string: "")` - The username you want to pass through to the client. +This value may be overridden by credentials sourced from a credential store. +You can also specify a username using the **BOUNDARY_CONNECT_USERNAME** environment variable. + +@include 'cmd-option-note.mdx' diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 535d032ac2..cc8046e551 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -1237,6 +1237,10 @@ "title": "postgres", "path": "commands/connect/postgres" }, + { + "title": "redis", + "path": "commands/connect/redis" + }, { "title": "rdp", "path": "commands/connect/rdp"