From 8b8fb282283a9c935cdf3f4f2663f33194136d95 Mon Sep 17 00:00:00 2001 From: Bharath Gajjala <120367134+bgajjala8@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:07:27 -0500 Subject: [PATCH] feat: add MySQL command support PORT (#5891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add MySQL command support * Fix MySQL connection according to review feedback * Update internal/cmd/commands/connect/mysql.go Co-authored-by: Johan Brandhorst-Satzkorn * Update internal/cmd/commands/connect/mysql.go Co-authored-by: Johan Brandhorst-Satzkorn * Update internal/cmd/commands/connect/mysql.go Co-authored-by: Johan Brandhorst-Satzkorn * feat: add MySQL support to boundary connect * docs: add MySQL to navigation menu Fixes content check error by adding mysql.mdx to docs navigation. MySQL page now appears in Commands > connect section alongside postgres. * Update testing/internal/e2e/tests/base_plus/target_tcp_connect_mysql_test.go Co-authored-by: Johan Brandhorst-Satzkorn * test: add containerized MySQL connection test for target functionality * Remove MySQL environment variables from e2e test config * test: move MySQL e2e test from base_plus to base folder * Fix MySQL test to support both MySQL and MariaDB clients * Fix MySQL test to support both MySQL and MariaDB clients * lint cleanup * adding error checks * Make gen * Test case testing * Reverting bad comment * cleaning up extra test file * Add Mysql to runner (#5893) * Adding back correct test file * Removing base plus test file * Removing base plus test file * Revert "Removing base plus test file" This reverts commit ace9cb3e90a028adc1f7c8ef55436ca843abfc15. * forcing enos docker network name * Revert "forcing enos docker network name" This reverts commit 631bdb4a0ef87f5435e06456e8ec0a4573ab3e49. * changing hosts to looks for sql container * fix container name * Adjusting for e2e tests and a bit of cleanup * missed a commented string * Bgajjala mysql e2etest v2 (#5900) * Using Env Vars rather than parsing uri * Comments fix * spacing * Update testing/internal/e2e/tests/base/target_tcp_connect_mysql_test.go * Spacing * Update internal/cmd/commands/connect/mysql.go * adding error checks * Make gen * Update testing/internal/e2e/tests/base/target_tcp_connect_mysql_test.go * Update internal/cmd/commands/connect/mysql.go * Make gen * derving target port from uri * nit * updating test to use url strings * nit: spacing * nit: code organization * nit: code organization Co-authored-by: Enbiya Göral --------- Co-authored-by: Enbiya Co-authored-by: Enbiya Göral <100806254+enbiyagoral@users.noreply.github.com> Co-authored-by: Johan Brandhorst-Satzkorn Co-authored-by: Ryan Derr <54389874+RyanDerr@users.noreply.github.com> --- CHANGELOG.md | 4 + enos/modules/test_e2e_docker/test.sh | 3 +- internal/cmd/commands.go | 6 + internal/cmd/commands/connect/connect.go | 20 +++ internal/cmd/commands/connect/mysql.go | 120 +++++++++++++++++ testing/internal/e2e/infra/docker.go | 41 ++++++ .../base/target_tcp_connect_mysql_test.go | 127 ++++++++++++++++++ .../content/docs/commands/connect/index.mdx | 2 + .../content/docs/commands/connect/mysql.mdx | 74 ++++++++++ website/data/docs-nav-data.json | 4 + 10 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/commands/connect/mysql.go create mode 100644 testing/internal/e2e/tests/base/target_tcp_connect_mysql_test.go create mode 100644 website/content/docs/commands/connect/mysql.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1097bef739..4d53579d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/enos/modules/test_e2e_docker/test.sh b/enos/modules/test_e2e_docker/test.sh index a9aea3a125..9935379db4 100755 --- a/enos/modules/test_e2e_docker/test.sh +++ b/enos/modules/test_e2e_docker/test.sh @@ -14,7 +14,8 @@ apt update # pass is used to store the auth token from `boundary authenticate`` # lsb-release is used for adding the hashicorp apt source # postgresql-client is used for postgres tests -apt install unzip pass lsb-release postgresql-client -y +# default-mysql-client is used for mysql tests +apt install unzip pass lsb-release postgresql-client default-mysql-client -y # Create a GPG key export KEY_PW=boundary diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index ed992c901d..782d340870 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -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...), diff --git a/internal/cmd/commands/connect/connect.go b/internal/cmd/commands/connect/connect.go index d5e569ac4f..41a660c7ea 100644 --- a/internal/cmd/commands/connect/connect.go +++ b/internal/cmd/commands/connect/connect.go @@ -77,6 +77,9 @@ type Command struct { // Postgres postgresFlags + // MySQL + mysqlFlags + // RDP rdpFlags @@ -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": @@ -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) @@ -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": @@ -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)...) diff --git a/internal/cmd/commands/connect/mysql.go b/internal/cmd/commands/connect/mysql.go new file mode 100644 index 0000000000..17f48da727 --- /dev/null +++ b/internal/cmd/commands/connect/mysql.go @@ -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 +} diff --git a/testing/internal/e2e/infra/docker.go b/testing/internal/e2e/infra/docker.go index 1a412f97f7..d8c2dec84b 100644 --- a/testing/internal/e2e/infra/docker.go +++ b/testing/internal/e2e/infra/docker.go @@ -303,3 +303,44 @@ 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("mysql://%s:%s@localhost:3306/%s", mysqlUser, mysqlPassword, mysqlDb), + UriNetwork: fmt.Sprintf("mysql://%s:%s@%s:3306/%s", mysqlUser, mysqlPassword, networkAlias, mysqlDb), + } +} diff --git a/testing/internal/e2e/tests/base/target_tcp_connect_mysql_test.go b/testing/internal/e2e/tests/base/target_tcp_connect_mysql_test.go new file mode 100644 index 0000000000..6b306fd233 --- /dev/null +++ b/testing/internal/e2e/tests/base/target_tcp_connect_mysql_test.go @@ -0,0 +1,127 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package base_test + +import ( + "bytes" + "context" + "io" + "net/url" + "os/exec" + "testing" + + "github.com/creack/pty" + "github.com/hashicorp/boundary/internal/target" + "github.com/hashicorp/boundary/testing/internal/e2e" + "github.com/hashicorp/boundary/testing/internal/e2e/boundary" + "github.com/hashicorp/boundary/testing/internal/e2e/infra" + "github.com/ory/dockertest/v3" + "github.com/stretchr/testify/require" +) + +// TestCliTcpTargetConnectMysql uses the boundary cli to connect to a +// target using `connect mysql` +func TestCliTcpTargetConnectMysql(t *testing.T) { + e2e.MaybeSkipTest(t) + ctx := context.Background() + + pool, err := dockertest.NewPool("") + require.NoError(t, err) + + // e2e_cluster network is created by the e2e infra setup + network, err := pool.NetworksByName("e2e_cluster") + require.NoError(t, err, "Failed to get e2e_cluster network") + + c := infra.StartMysql(t, pool, &network[0], "mysql", "8.0") + require.NotNil(t, c, "MySQL container should not be nil") + t.Cleanup(func() { + if err := pool.Purge(c.Resource); err != nil { + t.Logf("Failed to purge MySQL container: %v", err) + } + }) + + u, err := url.Parse(c.UriNetwork) + require.NoError(t, err, "Failed to parse MySQL URL") + + user, hostname, port, db := u.User.Username(), u.Hostname(), u.Port(), u.Path[1:] + pw, pwSet := u.User.Password() + t.Logf("MySQL info: user=%s, db=%s, host=%s, port=%s, password-set:%t", + user, db, hostname, port, pwSet) + + // Wait for MySQL to be ready + err = pool.Retry(func() error { + return exec.CommandContext(ctx, "docker", "exec", hostname, + "mysql", "-u"+user, "-p"+pw, "-e", "SELECT 1").Run() + }) + require.NoError(t, err, "MySQL 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) + + cmd := exec.CommandContext(ctx, + "boundary", + "connect", "mysql", + "-target-id", targetId, + "-dbname", db, + ) + 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")) + require.NoError(t, err) + _, err = f.Write([]byte("SELECT DATABASE();\n")) + require.NoError(t, err) + _, err = f.Write([]byte("EXIT;\n")) + require.NoError(t, err) + _, err = f.Write([]byte{4}) + require.NoError(t, err) + + var buf bytes.Buffer + _, _ = io.Copy(&buf, f) + + output := buf.String() + t.Logf("MySQL session output: %s", output) + + require.Contains(t, output, "| "+db+" |", "Session did not return expected database query result") + t.Log("Successfully connected to MySQL target") +} diff --git a/website/content/docs/commands/connect/index.mdx b/website/content/docs/commands/connect/index.mdx index 119384cab6..9751c7e0f1 100644 --- a/website/content/docs/commands/connect/index.mdx +++ b/website/content/docs/commands/connect/index.mdx @@ -41,6 +41,7 @@ Usage: boundary connect [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 @@ -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) diff --git a/website/content/docs/commands/connect/mysql.mdx b/website/content/docs/commands/connect/mysql.mdx new file mode 100644 index 0000000000..12e26b73fd --- /dev/null +++ b/website/content/docs/commands/connect/mysql.mdx @@ -0,0 +1,74 @@ +--- +layout: docs +page_title: connect mysql - Command +description: >- + The "connect mysql" command performs a target authorization or consumes an existing authorization token, and then launches a proxied MySQL connection. +--- + +# connect mysql + +Command: `boundary connect mysql` + +The `connect mysql` command authorizes a session against a target and invokes a MySQL 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_eTcMueUYv` using a MySQL helper: + +```shell-session +$ boundary connect mysql -target-id=ttcp_eTcZMueUYv \ + -dbname=northwind \ + -username=superuser \ + -format=table +``` + +When prompted, you must enter the password for the user, "superuser": + + + +```plaintext +Enter password: +Welcome to the MySQL monitor. Commands end with ; or \g. +Your MySQL connection id is 8 +Server version: 8.0.30 MySQL Community Server - GPL + +Copyright (c) 2000, 2022, Oracle and/or its affiliates. + +Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. + +mysql> +``` + + + +## Usage + + + +```shell-session +$ boundary connect mysql [options] [args] +``` + + + +@include 'cmd-connect-command-options.mdx' + +### MySQL options: + +- `-dbname` `(string: "")` - The database name you want to pass through to the client. +You can also specify the database name using the **BOUNDARY_CONNECT_DBNAME** environment variable. + +- `-style` `(string: "")` - How the CLI attempts to invoke a MySQL client. +This value also sets a suitable default for `-exec`, if you did not specify a value. +The default and currently-understood value is `mysql`. +You can also specify how the CLI attempts to invoke a MySQL client using the **BOUNDARY_CONNECT_MYSQL_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 specfiy a username using the **BOUNDARY_CONNECT_USERNAME** environment variable. + +@include 'cmd-option-note.mdx' \ No newline at end of file diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 471f84ee71..9944ef8746 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -1003,6 +1003,10 @@ "title": "kube", "path": "commands/connect/kube" }, + { + "title": "mysql", + "path": "commands/connect/mysql" + }, { "title": "postgres", "path": "commands/connect/postgres"