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..7c4d41d6c4 --- /dev/null +++ b/internal/cmd/commands/connect/mysql.go @@ -0,0 +1,114 @@ +// 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": + 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) + } + + 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.WriteString(fmt.Sprintf("[client]\npassword=%s", password)) + if err != nil { + 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) + } + args = append(args, "-p"+password) + + if c.flagDbname == "" { + c.UI.Warn("Credentials are being brokered but no -dbname parameter provided. mysql may misinterpret another parameter as the database name.") + } + } + } + return +} \ No newline at end of file