Skip to content

feat: add MySQL command support #5749

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Canonical reference for changes, improvements, and bugfixes for Boundary.

### New and Improved

* cli: Added `boundary connect mysql` command for connecting to MySQL targets.
This new helper command allows users to authorize sessions against MySQL
targets and automatically invoke a MySQL client with the appropriate
connection parameters and credentials.
* Adds support to parse User-Agent headers and emit them in telemetry events
([PR](https://github.com/hashicorp/boundary/pull/5645)).

Expand Down
6 changes: 6 additions & 0 deletions internal/cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,12 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) {
Func: "postgres",
}
}),
"connect mysql": wrapper.Wrap(func() wrapper.WrappableCommand {
return &connect.Command{
Command: base.NewCommand(ui, opts...),
Func: "mysql",
}
}),
"connect rdp": wrapper.Wrap(func() wrapper.WrappableCommand {
return &connect.Command{
Command: base.NewCommand(ui, opts...),
Expand Down
20 changes: 20 additions & 0 deletions internal/cmd/commands/connect/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ type Command struct {
// Postgres
postgresFlags

// MySQL
mysqlFlags

// RDP
rdpFlags

Expand All @@ -103,6 +106,8 @@ func (c *Command) Synopsis() string {
return httpSynopsis
case "postgres":
return postgresSynopsis
case "mysql":
return mysqlSynopsis
case "rdp":
return rdpSynopsis
case "ssh":
Expand Down Expand Up @@ -222,6 +227,9 @@ func (c *Command) Flags() *base.FlagSets {
case "postgres":
postgresOptions(c, set)

case "mysql":
mysqlOptions(c, set)

case "rdp":
rdpOptions(c, set)

Expand Down Expand Up @@ -309,6 +317,8 @@ func (c *Command) Run(args []string) (retCode int) {
c.flagExec = c.sshFlags.defaultExec()
case "postgres":
c.flagExec = c.postgresFlags.defaultExec()
case "mysql":
c.flagExec = c.mysqlFlags.defaultExec()
case "rdp":
c.flagExec = c.rdpFlags.defaultExec()
case "kube":
Expand Down Expand Up @@ -641,6 +651,16 @@ func (c *Command) handleExec(clientProxy *apiproxy.ClientProxy, passthroughArgs
envs = append(envs, pgEnvs...)
creds = pgCreds

case "mysql":
mysqlArgs, mysqlEnvs, mysqlCreds, mysqlErr := c.mysqlFlags.buildArgs(c, port, host, addr, creds)
if mysqlErr != nil {
argsErr = mysqlErr
break
}
args = append(args, mysqlArgs...)
envs = append(envs, mysqlEnvs...)
creds = mysqlCreds

case "rdp":
args = append(args, c.rdpFlags.buildArgs(c, port, host, addr)...)

Expand Down
120 changes: 120 additions & 0 deletions internal/cmd/commands/connect/mysql.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package connect

import (
"fmt"
"os"
"strings"

"github.com/hashicorp/boundary/api/proxy"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/posener/complete"
)

const (
mysqlSynopsis = "Authorize a session against a target and invoke a MySQL client to connect"
)

func mysqlOptions(c *Command, set *base.FlagSets) {
f := set.NewFlagSet("MySQL Options")

f.StringVar(&base.StringVar{
Name: "style",
Target: &c.flagMySQLStyle,
EnvVar: "BOUNDARY_CONNECT_MYSQL_STYLE",
Completion: complete.PredictSet("mysql"),
Default: "mysql",
Usage: `Specifies how the CLI will attempt to invoke a MySQL client. This will also set a suitable default for -exec if a value was not specified. Currently-understood values are "mysql".`,
})

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.`,
})

f.StringVar(&base.StringVar{
Name: "dbname",
Target: &c.flagDbname,
EnvVar: "BOUNDARY_CONNECT_DBNAME",
Completion: complete.PredictNothing,
Usage: `Specifies the database name to pass through to the client.`,
})
}

type mysqlFlags struct {
flagMySQLStyle string
}

func (m *mysqlFlags) defaultExec() string {
return strings.ToLower(m.flagMySQLStyle)
}

func (m *mysqlFlags) 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 so it is not printed to user
retCreds.UsernamePassword[0].Consumed = true

// For now just grab the first username password credential brokered
username = retCreds.UsernamePassword[0].Username
password = retCreds.UsernamePassword[0].Password
}

switch m.flagMySQLStyle {
case "mysql":
// Handle password first - defaults-file must be the first argument
if password != "" {
passfile, err := os.CreateTemp("", "*")
if err != nil {
return nil, nil, proxy.Credentials{}, fmt.Errorf("Error saving MySQL password to tmp file: %w", err)
}
c.cleanupFuncs = append(c.cleanupFuncs, func() error {
if err := os.Remove(passfile.Name()); err != nil {
return fmt.Errorf("Error removing temporary password file; consider removing %s manually: %w", passfile.Name(), err)
}
return nil
})
_, err = passfile.Write([]byte("[client]\npassword=" + password))
if err != nil {
_ = passfile.Close()
return nil, nil, proxy.Credentials{}, fmt.Errorf("Error writing password file to %s: %w", passfile.Name(), err)
}
if err := passfile.Close(); err != nil {
return nil, nil, proxy.Credentials{}, fmt.Errorf("Error closing password file after writing to %s: %w", passfile.Name(), err)
}
// --defaults-file must be the first argument
args = append([]string{"--defaults-file=" + passfile.Name()}, args...)

if c.flagDbname == "" {
c.UI.Warn("Credentials are being brokered but no -dbname parameter provided. mysql may misinterpret another parameter as the database name.")
}
} else {
// If no password provided, add -p to prompt for password
args = append(args, "-p")
}

if port != "" {
args = append(args, "-P", port)
}
args = append(args, "-h", ip)

if c.flagDbname != "" {
args = append(args, "-D", c.flagDbname)
}

switch {
case username != "":
args = append(args, "-u", username)
case c.flagUsername != "":
args = append(args, "-u", c.flagUsername)
}
}
return
}
52 changes: 52 additions & 0 deletions testing/internal/e2e/infra/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,55 @@ func StartOpenSshServer(t testing.TB, pool *dockertest.Pool, network *dockertest
UriNetwork: networkAlias,
}
}

// StartMysql starts a MySQL database in a docker container.
// Returns information about the container
func StartMysql(t testing.TB, pool *dockertest.Pool, network *dockertest.Network, repository, tag string) *Container {
t.Log("Starting MySQL 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)

networkAlias := "e2emysql"
mysqlDb := "e2eboundarydb"
mysqlUser := "e2eboundary"
mysqlPassword := "e2eboundary"
mysqlRootPassword := "rootpassword"

resource, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: fmt.Sprintf("%s/%s", c.DockerMirror, repository),
Tag: tag,
Env: []string{
"MYSQL_DATABASE=" + mysqlDb,
"MYSQL_USER=" + mysqlUser,
"MYSQL_PASSWORD=" + mysqlPassword,
"MYSQL_ROOT_PASSWORD=" + mysqlRootPassword,
},
ExposedPorts: []string{"3306/tcp"},
Name: networkAlias,
Networks: []*dockertest.Network{network},
})
require.NoError(t, err)

return &Container{
Resource: resource,
UriLocalhost: fmt.Sprintf("%s:%s@tcp(%s)/%s",
mysqlUser,
mysqlPassword,
resource.GetHostPort("3306/tcp"),
mysqlDb,
),
UriNetwork: fmt.Sprintf("%s:%s@tcp(%s:%s)/%s",
mysqlUser,
mysqlPassword,
networkAlias,
"3306",
mysqlDb,
),
}
}
3 changes: 3 additions & 0 deletions testing/internal/e2e/tests/base_plus/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ type config struct {
PostgresDbName string `envconfig:"E2E_POSTGRES_DB_NAME" required:"true"`
PostgresUser string `envconfig:"E2E_POSTGRES_USER" required:"true"`
PostgresPassword string `envconfig:"E2E_POSTGRES_PASSWORD" required:"true"`
MysqlDbName string `envconfig:"E2E_MYSQL_DB_NAME" required:"true"`
MysqlUser string `envconfig:"E2E_MYSQL_USER" required:"true"`
MysqlPassword string `envconfig:"E2E_MYSQL_PASSWORD" required:"true"`
LdapAddress string `envconfig:"E2E_LDAP_ADDR" required:"true"` // e.g. ldap://ldap
LdapDomainDn string `envconfig:"E2E_LDAP_DOMAIN_DN" required:"true"` // e.g. dc=example,dc=org
LdapAdminDn string `envconfig:"E2E_LDAP_ADMIN_DN" required:"true"` // e.g. cn=admin,dc=example,dc=org
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package base_plus_test

import (
"bytes"
"context"
"io"
"os/exec"
"testing"

"github.com/creack/pty"
"github.com/stretchr/testify/require"

"github.com/hashicorp/boundary/internal/target"
"github.com/hashicorp/boundary/testing/internal/e2e"
"github.com/hashicorp/boundary/testing/internal/e2e/boundary"
)

// TestCliTcpTargetConnectMysql uses the boundary cli to connect to a
// target using `connect mysql`
func TestCliTcpTargetConnectMysql(t *testing.T) {
e2e.MaybeSkipTest(t)
c, err := loadTestConfig()
require.NoError(t, err)

ctx := context.Background()
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,
c.TargetPort,
target.WithAddress(c.TargetAddress),
)
require.NoError(t, err)
storeId, err := boundary.CreateCredentialStoreStaticCli(t, ctx, projectId)
require.NoError(t, err)
credentialId, err := boundary.CreateStaticCredentialPasswordCli(
t,
ctx,
storeId,
c.MysqlUser,
c.MysqlPassword,
)
require.NoError(t, err)
err = boundary.AddBrokeredCredentialSourceToTargetCli(t, ctx, targetId, credentialId)
require.NoError(t, err)

var cmd *exec.Cmd
cmd = exec.CommandContext(ctx,
"boundary",
"connect", "mysql",
"-target-id", targetId,
"-dbname", c.MysqlDbName,
)
f, err := pty.Start(cmd)
require.NoError(t, err)
t.Cleanup(func() {
err := f.Close()
require.NoError(t, err)
})

_, err = f.Write([]byte("SHOW TABLES;\n")) // list all tables
require.NoError(t, err)
_, err = f.Write([]byte("SELECT DATABASE();\n")) // show current database
require.NoError(t, err)
_, err = f.Write([]byte("EXIT;\n")) // exit mysql session
require.NoError(t, err)
_, err = f.Write([]byte{4}) // EOT (End of Transmission - marks end of file stream)
require.NoError(t, err)

var buf bytes.Buffer
_, _ = io.Copy(&buf, f)
require.Contains(t, buf.String(), "mysql>", "Session did not return expected MySQL prompt")
require.Contains(t, buf.String(), c.MysqlDbName, "Session did not return expected output")
t.Log("Successfully connected to MySQL target")
}
2 changes: 2 additions & 0 deletions website/content/docs/commands/connect/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Usage: boundary connect <subcommand> [options] [args]
Subcommands:
http Authorize a session against a target and invoke an HTTP client to connect
kube Authorize a session against a target and invoke a Kubernetes 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
rdp Authorize a session against a target and invoke an RDP client to connect
ssh Authorize a session against a target and invoke an SSH client to connect
Expand All @@ -53,6 +54,7 @@ of the subcommand in the sidebar or one of the links below:

- [http](/boundary/docs/commands/connect/http)
- [kube](/boundary/docs/commands/connect/kube)
- [mysql](/boundary/docs/commands/connect/mysql)
- [postgres](/boundary/docs/commands/connect/postgres)
- [rdp](/boundary/docs/commands/connect/rdp)
- [ssh](/boundary/docs/commands/connect/ssh)
Expand Down
Loading