ICU-14484: redis connect helper (#6001)

* add redis connect support
* Setup redis in docker for e2e testing
* Update docs and changelog
* Commenting out e2e tests for now, will revisit in a separate task
This commit is contained in:
Justin Nguyen authored and GitHub committed 2025-09-19 16:20:11 -05:00
1 parent f8a5894e29
commit 4548028efe
10 files changed
+386

No files matched your search

+5
View File
@@ -16,12 +16,17 @@ Canonical reference for changes, improvements, and bugfixes for Boundary.
This new helper command allows users to authorize sessions against Cassandra This new helper command allows users to authorize sessions against Cassandra
targets and automatically invoke a Cassandra client with the appropriate targets and automatically invoke a Cassandra client with the appropriate
connection parameters and credentials. Currently only username/password credentials are automatically attached. 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)) * 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 ### 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 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)) * 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 ### Deprecations/Changes
* Modified parsing logic for various IP/host/address fields across Boundary. * Modified parsing logic for various IP/host/address fields across Boundary.
+2
View File
@@ -41,6 +41,8 @@ install_cassandra() {
# Install Cassandra # Install Cassandra
install_cassandra install_cassandra
# Install Redis
apt install redis-server -y
# Create a GPG key # Create a GPG key
export KEY_PW=boundary export KEY_PW=boundary
+6
View File
@@ -423,6 +423,12 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) {
Func: "cassandra", 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 { "connect rdp": wrapper.Wrap(func() wrapper.WrappableCommand {
return &connect.Command{ return &connect.Command{
Command: base.NewCommand(ui, opts...), Command: base.NewCommand(ui, opts...),
+20
View File
@@ -84,6 +84,9 @@ type Command struct {
// Cassandra // Cassandra
cassandraFlags cassandraFlags
// Redis
redisFlags
// RDP // RDP
rdpFlags rdpFlags
@@ -114,6 +117,8 @@ func (c *Command) Synopsis() string {
return mysqlSynopsis return mysqlSynopsis
case "cassandra": case "cassandra":
return cassandraSynopsis return cassandraSynopsis
case "redis":
return redisSynopsis
case "rdp": case "rdp":
return rdpSynopsis return rdpSynopsis
case "ssh": case "ssh":
@@ -239,6 +244,9 @@ func (c *Command) Flags() *base.FlagSets {
case "cassandra": case "cassandra":
cassandraOptions(c, set) cassandraOptions(c, set)
case "redis":
redisOptions(c, set)
case "rdp": case "rdp":
rdpOptions(c, set) rdpOptions(c, set)
@@ -330,6 +338,8 @@ func (c *Command) Run(args []string) (retCode int) {
c.flagExec = c.mysqlFlags.defaultExec() c.flagExec = c.mysqlFlags.defaultExec()
case "cassandra": case "cassandra":
c.flagExec = c.cassandraFlags.defaultExec() c.flagExec = c.cassandraFlags.defaultExec()
case "redis":
c.flagExec = c.redisFlags.defaultExec()
case "rdp": case "rdp":
c.flagExec = c.rdpFlags.defaultExec() c.flagExec = c.rdpFlags.defaultExec()
case "kube": case "kube":
@@ -714,6 +724,16 @@ func (c *Command) handleExec(clientProxy *apiproxy.ClientProxy, passthroughArgs
envs = append(envs, cassandraEnvs...) envs = append(envs, cassandraEnvs...)
creds = cassandraCreds 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": case "rdp":
args = append(args, c.rdpFlags.buildArgs(c, port, host, addr)...) args = append(args, c.rdpFlags.buildArgs(c, port, host, addr)...)
+82
View File
@@ -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
}
+79
View File
@@ -35,6 +35,12 @@ type cassandraConfig struct {
NetworkAlias string NetworkAlias string
} }
type redisConfig struct {
User string
Password string
NetworkAlias string
}
// StartBoundaryDatabase spins up a postgres database in a docker container. // StartBoundaryDatabase spins up a postgres database in a docker container.
// Returns information about the container // Returns information about the container
func StartBoundaryDatabase(t testing.TB, pool *dockertest.Pool, network *dockertest.Network, repository, tag string) *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. // 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 { func setupCassandraAuthAndUser(t testing.TB, resource *dockertest.Resource, pool *dockertest.Pool, config *cassandraConfig) error {
t.Helper() t.Helper()
@@ -479,3 +542,19 @@ func setupCassandraAuthAndUser(t testing.TB, resource *dockertest.Resource, pool
} }
return nil 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
}
@@ -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")
}
@@ -45,6 +45,7 @@ Subcommands:
mysql Authorize a session against a target and invoke a MySQL client to connect 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 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 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 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) - [mysql](/boundary/docs/commands/connect/mysql)
- [postgres](/boundary/docs/commands/connect/postgres) - [postgres](/boundary/docs/commands/connect/postgres)
- [rdp](/boundary/docs/commands/connect/rdp) - [rdp](/boundary/docs/commands/connect/rdp)
- [redis] (/boundary/docs/commands/connect/redis)
- [ssh](/boundary/docs/commands/connect/ssh) - [ssh](/boundary/docs/commands/connect/ssh)
### Command options ### Command options
@@ -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":
<CodeBlockConfig hideClipboard>
```plaintext
Please input password:
127.0.0.1:60835>
```
</CodeBlockConfig>
## Usage
<CodeBlockConfig hideClipboard>
```shell-session
$ boundary connect redis [options] [args]
```
</CodeBlockConfig>
@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'
+4
View File
@@ -1237,6 +1237,10 @@
"title": "postgres", "title": "postgres",
"path": "commands/connect/postgres" "path": "commands/connect/postgres"
}, },
{
"title": "redis",
"path": "commands/connect/redis"
},
{ {
"title": "rdp", "title": "rdp",
"path": "commands/connect/rdp" "path": "commands/connect/rdp"