From 9daa6d0cfee635934600fea1eb12447d8d5e051c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Cie=C5=9Blak?= Date: Wed, 27 Apr 2022 13:01:38 +0200 Subject: [PATCH] [v9] Backport quoting Postgres connection string & generating DB CLI commands for Teleport Connect (#12206) * Quote postgres connection string for printing to terminals (#11843) * Generate DB CLI commands for Teleterm from tsh daemon (#11835) * Extract dbcmd.go into a new package under lib/client/db/dbcmd * Use dbcmd to generate CliCommand for gateways * Return relative db command from tsh daemon * Add WithTolarateMissingCLIClient func to dbcmd Co-authored-by: STeve (Xin) Huang --- {tool/tsh => lib/client/db/dbcmd}/dbcmd.go | 198 +++++-- lib/client/db/dbcmd/dbcmd_test.go | 548 ++++++++++++++++++ lib/client/db/postgres/connstring.go | 11 +- lib/client/db/postgres/connstring_test.go | 2 +- lib/teleterm/api/proto/v1/gateway.proto | 15 +- .../api/protogen/golang/v1/gateway.pb.go | 61 +- .../api/protogen/js/v1/gateway_pb.d.ts | 18 +- lib/teleterm/api/protogen/js/v1/gateway_pb.js | 108 +--- .../apiserver/handler/handler_gateways.go | 5 +- lib/teleterm/clusters/cluster_gateways.go | 31 +- lib/teleterm/gateway/config.go | 6 +- lib/teleterm/gateway/gateway.go | 13 + tool/tsh/db.go | 45 +- tool/tsh/db_test.go | 359 ------------ tool/tsh/proxy.go | 9 +- 15 files changed, 820 insertions(+), 609 deletions(-) rename {tool/tsh => lib/client/db/dbcmd}/dbcmd.go (61%) create mode 100644 lib/client/db/dbcmd/dbcmd_test.go diff --git a/tool/tsh/dbcmd.go b/lib/client/db/dbcmd/dbcmd.go similarity index 61% rename from tool/tsh/dbcmd.go rename to lib/client/db/dbcmd/dbcmd.go index be5a2788b6c26..765658579c714 100644 --- a/tool/tsh/dbcmd.go +++ b/lib/client/db/dbcmd/dbcmd.go @@ -16,11 +16,12 @@ */ -package main +package dbcmd import ( "fmt" "os/exec" + "path/filepath" "strconv" "strings" @@ -33,6 +34,8 @@ import ( "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/trace" + + "github.com/sirupsen/logrus" ) const ( @@ -62,6 +65,8 @@ type execer interface { // LookPath returns a full path to a binary if this one is found in system PATH, // error otherwise. LookPath(file string) (string, error) + // Command returns the Cmd struct to execute the named program with the given arguments. + Command(name string, arg ...string) *exec.Cmd } // systemExecer implements execer interface by using Go exec module. @@ -77,7 +82,15 @@ func (s systemExecer) LookPath(file string) (string, error) { return exec.LookPath(file) } -type cliCommandBuilder struct { +// Command is a wrapper for exec.Command(...) +func (s systemExecer) Command(name string, arg ...string) *exec.Cmd { + return exec.Command(name, arg...) +} + +// CLICommandBuilder holds data needed to build a CLI command from args passed to NewCmdBuilder. +// Any calls to the exec package within CLICommandBuilder methods that need to be mocked should +// use the exe field rather than calling the package directly. +type CLICommandBuilder struct { tc *client.TeleportClient rootCluster string profile *client.ProfileStatus @@ -90,9 +103,9 @@ type cliCommandBuilder struct { exe execer } -func newCmdBuilder(tc *client.TeleportClient, profile *client.ProfileStatus, +func NewCmdBuilder(tc *client.TeleportClient, profile *client.ProfileStatus, db *tlsca.RouteToDatabase, rootClusterName string, opts ...ConnectCommandFunc, -) *cliCommandBuilder { +) *CLICommandBuilder { var options connectionCommandOpts for _, opt := range opts { opt(&options) @@ -105,7 +118,11 @@ func newCmdBuilder(tc *client.TeleportClient, profile *client.ProfileStatus, port = options.localProxyPort } - return &cliCommandBuilder{ + if options.log == nil { + options.log = logrus.NewEntry(logrus.StandardLogger()) + } + + return &CLICommandBuilder{ tc: tc, profile: profile, db: db, @@ -119,7 +136,18 @@ func newCmdBuilder(tc *client.TeleportClient, profile *client.ProfileStatus, } } -func (c *cliCommandBuilder) getConnectCommand() (*exec.Cmd, error) { +// GetConnectCommand returns a command that can connect the user directly to the given database +// using an appropriate CLI database client. It takes into account cluster configuration, binaries +// available on the system and in some cases it even connects to the database to check which exact +// version of the database the user is running. +// +// Underneath it uses exec.Command, so the resulting command will always be expanded to its absolute +// path if exec.LookPath was able to find the given binary on user's system. +// +// If CLICommandBuilder's options.tolerateMissingCLIClient is set to true, GetConnectCommand +// shouldn't return an error if it cannot locate a client binary. Check WithTolerateMissingCLIClient +// docs for more details. +func (c *CLICommandBuilder) GetConnectCommand() (*exec.Cmd, error) { switch c.db.Protocol { case defaults.ProtocolPostgres: return c.getPostgresCommand(), nil @@ -143,26 +171,51 @@ func (c *cliCommandBuilder) getConnectCommand() (*exec.Cmd, error) { return nil, trace.BadParameter("unsupported database protocol: %v", c.db) } -func (c *cliCommandBuilder) getPostgresCommand() *exec.Cmd { - return exec.Command(postgresBin, - postgres.GetConnString(db.New(c.tc, *c.db, *c.profile, c.rootCluster, c.host, c.port), c.options.noTLS)) +// GetConnectCommandNoAbsPath works just like GetConnectCommand, with the only difference being that +// it guarantees that the command will always be in its base form, never in an absolute path +// resolved to the binary location. This is useful for situations where the resulting command is +// meant to be copied and then pasted into an interactive shell, rather than being run directly +// by a tool like tsh. +func (c *CLICommandBuilder) GetConnectCommandNoAbsPath() (*exec.Cmd, error) { + cmd, err := c.GetConnectCommand() + + if err != nil { + return nil, trace.Wrap(err) + } + + if filepath.IsAbs(cmd.Path) { + cmd.Path = filepath.Base(cmd.Path) + } + + return cmd, nil +} + +func (c *CLICommandBuilder) getPostgresCommand() *exec.Cmd { + return c.exe.Command(postgresBin, c.getPostgresConnString()) } -func (c *cliCommandBuilder) getCockroachCommand() *exec.Cmd { +func (c *CLICommandBuilder) getCockroachCommand() *exec.Cmd { // If cockroach CLI client is not available, fallback to psql. if _, err := c.exe.LookPath(cockroachBin); err != nil { - log.Debugf("Couldn't find %q client in PATH, falling back to %q: %v.", + c.options.log.Debugf("Couldn't find %q client in PATH, falling back to %q: %v.", cockroachBin, postgresBin, err) - return exec.Command(postgresBin, - postgres.GetConnString(db.New(c.tc, *c.db, *c.profile, c.rootCluster, c.host, c.port), c.options.noTLS)) + return c.getPostgresCommand() } - return exec.Command(cockroachBin, "sql", "--url", - postgres.GetConnString(db.New(c.tc, *c.db, *c.profile, c.rootCluster, c.host, c.port), c.options.noTLS)) + return c.exe.Command(cockroachBin, "sql", "--url", c.getPostgresConnString()) +} + +// getPostgresConnString returns the connection string for postgres. +func (c *CLICommandBuilder) getPostgresConnString() string { + return postgres.GetConnString( + db.New(c.tc, *c.db, *c.profile, c.rootCluster, c.host, c.port), + c.options.noTLS, + c.options.printFormat, + ) } // getMySQLCommonCmdOpts returns common command line arguments for mysql and mariadb. // Currently, the common options are: user, database, host, port and protocol. -func (c *cliCommandBuilder) getMySQLCommonCmdOpts() []string { +func (c *CLICommandBuilder) getMySQLCommonCmdOpts() []string { args := make([]string, 0) if c.db.Username != "" { args = append(args, "--user", c.db.Username) @@ -186,7 +239,7 @@ func (c *cliCommandBuilder) getMySQLCommonCmdOpts() []string { // getMariaDBArgs returns arguments unique for mysql cmd shipped by MariaDB and mariadb cmd. Common options for mysql // between Oracle and MariaDB version are covered by getMySQLCommonCmdOpts(). -func (c *cliCommandBuilder) getMariaDBArgs() []string { +func (c *CLICommandBuilder) getMariaDBArgs() []string { args := c.getMySQLCommonCmdOpts() if c.options.noTLS { @@ -210,11 +263,11 @@ func (c *cliCommandBuilder) getMariaDBArgs() []string { // getMySQLOracleCommand returns arguments unique for mysql cmd shipped by Oracle. Common options between // Oracle and MariaDB version are covered by getMySQLCommonCmdOpts(). -func (c *cliCommandBuilder) getMySQLOracleCommand() *exec.Cmd { +func (c *CLICommandBuilder) getMySQLOracleCommand() *exec.Cmd { args := c.getMySQLCommonCmdOpts() if c.options.noTLS { - return exec.Command(mysqlBin, args...) + return c.exe.Command(mysqlBin, args...) } // defaults-group-suffix must be first. @@ -226,20 +279,25 @@ func (c *cliCommandBuilder) getMySQLOracleCommand() *exec.Cmd { args = append(args, fmt.Sprintf("--ssl-mode=%s", mysql.MySQLSSLModeVerifyCA)) } - return exec.Command(mysqlBin, args...) + return c.exe.Command(mysqlBin, args...) } // getMySQLCommand returns mariadb command if the binary is on the path. Otherwise, // mysql command is returned. Both mysql versions (MariaDB and Oracle) are supported. -func (c *cliCommandBuilder) getMySQLCommand() (*exec.Cmd, error) { +func (c *CLICommandBuilder) getMySQLCommand() (*exec.Cmd, error) { // Check if mariadb client is available. Prefer it over mysql client even if connecting to MySQL server. if c.isMariaDBBinAvailable() { args := c.getMariaDBArgs() - return exec.Command(mariadbBin, args...), nil + return c.exe.Command(mariadbBin, args...), nil } - // Check for mysql binary. Return with error as mysql and mariadb are missing. There is nothing else we can do here. + // Check for mysql binary. In case the caller doesn't tolerate a missing CLI client, return with + // error as mysql and mariadb are missing. There is nothing else we can do here. if !c.isMySQLBinAvailable() { + if c.options.tolerateMissingCLIClient { + return c.getMySQLOracleCommand(), nil + } + return nil, trace.NotFound("neither %q nor %q CLI clients were found, please make sure an appropriate CLI client is available in $PATH", mysqlBin, mariadbBin) } @@ -248,7 +306,7 @@ func (c *cliCommandBuilder) getMySQLCommand() (*exec.Cmd, error) { mySQLMariaDBFlavor, err := c.isMySQLBinMariaDBFlavor() if mySQLMariaDBFlavor && err == nil { args := c.getMariaDBArgs() - return exec.Command(mysqlBin, args...), nil + return c.exe.Command(mysqlBin, args...), nil } // Either we failed to check the flavor or binary comes from Oracle. Regardless return mysql/Oracle command. @@ -256,26 +314,26 @@ func (c *cliCommandBuilder) getMySQLCommand() (*exec.Cmd, error) { } // isMariaDBBinAvailable returns true if "mariadb" binary is found in the system PATH. -func (c *cliCommandBuilder) isMariaDBBinAvailable() bool { +func (c *CLICommandBuilder) isMariaDBBinAvailable() bool { _, err := c.exe.LookPath(mariadbBin) return err == nil } // isMySQLBinAvailable returns true if "mysql" binary is found in the system PATH. -func (c *cliCommandBuilder) isMySQLBinAvailable() bool { +func (c *CLICommandBuilder) isMySQLBinAvailable() bool { _, err := c.exe.LookPath(mysqlBin) return err == nil } // isMongoshBinAvailable returns true if "mongosh" binary is found in the system PATH. -func (c *cliCommandBuilder) isMongoshBinAvailable() bool { +func (c *CLICommandBuilder) isMongoshBinAvailable() bool { _, err := c.exe.LookPath(mongoshBin) return err == nil } // isMySQLBinMariaDBFlavor checks if mysql binary comes from Oracle or MariaDB. // true is returned when binary comes from MariaDB, false when from Oracle. -func (c *cliCommandBuilder) isMySQLBinMariaDBFlavor() (bool, error) { +func (c *CLICommandBuilder) isMySQLBinMariaDBFlavor() (bool, error) { // Check if mysql comes from Oracle or MariaDB mysqlVer, err := c.exe.RunCommand(mysqlBin, "--version") if err != nil { @@ -292,7 +350,7 @@ func (c *cliCommandBuilder) isMySQLBinMariaDBFlavor() (bool, error) { return strings.Contains(strings.ToLower(string(mysqlVer)), "mariadb"), nil } -func (c *cliCommandBuilder) getMongoCommand() *exec.Cmd { +func (c *CLICommandBuilder) getMongoCommand() *exec.Cmd { // look for `mongosh` hasMongosh := c.isMongoshBinAvailable() @@ -343,15 +401,15 @@ func (c *cliCommandBuilder) getMongoCommand() *exec.Cmd { // use `mongosh` if available if hasMongosh { - return exec.Command(mongoshBin, args...) + return c.exe.Command(mongoshBin, args...) } // fall back to `mongo` if `mongosh` isn't found - return exec.Command(mongoBin, args...) + return c.exe.Command(mongoBin, args...) } // getRedisCommand returns redis-cli commands used by 'tsh db connect' when connecting to a Redis instance. -func (c *cliCommandBuilder) getRedisCommand() *exec.Cmd { +func (c *CLICommandBuilder) getRedisCommand() *exec.Cmd { // TODO(jakub): Add "-3" when Teleport adds support for Redis RESP3 protocol. args := []string{ "-h", c.host, @@ -378,10 +436,10 @@ func (c *cliCommandBuilder) getRedisCommand() *exec.Cmd { args = append(args, []string{"-n", c.db.Database}...) } - return exec.Command(redisBin, args...) + return c.exe.Command(redisBin, args...) } -func (c *cliCommandBuilder) getSQLServerCommand() *exec.Cmd { +func (c *CLICommandBuilder) getSQLServerCommand() *exec.Cmd { args := []string{ // Host and port must be comma-separated. "-S", fmt.Sprintf("%v,%v", c.host, c.port), @@ -395,5 +453,75 @@ func (c *cliCommandBuilder) getSQLServerCommand() *exec.Cmd { args = append(args, "-d", c.db.Database) } - return exec.Command(mssqlBin, args...) + return c.exe.Command(mssqlBin, args...) +} + +type connectionCommandOpts struct { + localProxyPort int + localProxyHost string + caPath string + noTLS bool + printFormat bool + tolerateMissingCLIClient bool + log *logrus.Entry +} + +// ConnectCommandFunc is a type for functions returned by the "With*" functions in this package. +// A function of type ConnectCommandFunc changes connectionCommandOpts of CLICommandBuilder based on +// the arguments passed to a "With*" function. +type ConnectCommandFunc func(*connectionCommandOpts) + +// WithLocalProxy makes CLICommandBuilder pass appropriate args to the CLI database clients that +// will let them connect to a database through a local proxy. +// In most cases it means using the passed host and port as the address, but some database clients +// require additional flags in those scenarios. +func WithLocalProxy(host string, port int, caPath string) ConnectCommandFunc { + return func(opts *connectionCommandOpts) { + opts.localProxyPort = port + opts.localProxyHost = host + opts.caPath = caPath + } +} + +// WithNoTLS is the connect command option that makes the command connect +// without TLS. +// +// It is used when connecting through the local proxy that was started in +// mutual TLS mode (i.e. with a client certificate). +func WithNoTLS() ConnectCommandFunc { + return func(opts *connectionCommandOpts) { + opts.noTLS = true + } +} + +// WithPrintFormat is the connect command option that hints the command will be +// printed instead of being executed. +func WithPrintFormat() ConnectCommandFunc { + return func(opts *connectionCommandOpts) { + opts.printFormat = true + } +} + +// WithLogger is the connect command option that allows the caller to pass a logger that will be +// used by CLICommandBuilder. +func WithLogger(log *logrus.Entry) ConnectCommandFunc { + return func(opts *connectionCommandOpts) { + opts.log = log + } +} + +// WithTolerateMissingCLIClient is the connect command option that makes CLICommandBuilder not +// return an error in case a specific binary couldn't be found in the system. Instead it should +// return the command with just a base version of the binary name, without an absolute path. +// +// In general CLICommandBuilder doesn't return an error in that scenario as it uses exec.Command +// underneath. However, there are some specific situations where we need to execute some of the +// binaries before returning the final command. +// +// The flag is mostly for scenarios where the caller doesn't care that the final command might not +// work. +func WithTolerateMissingCLIClient() ConnectCommandFunc { + return func(opts *connectionCommandOpts) { + opts.tolerateMissingCLIClient = true + } } diff --git a/lib/client/db/dbcmd/dbcmd_test.go b/lib/client/db/dbcmd/dbcmd_test.go new file mode 100644 index 0000000000000..fac48e11cbca6 --- /dev/null +++ b/lib/client/db/dbcmd/dbcmd_test.go @@ -0,0 +1,548 @@ +// Copyright 2022 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dbcmd + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/fixtures" + "github.com/gravitational/teleport/lib/tlsca" + "github.com/gravitational/teleport/lib/utils" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" +) + +type commandPathBehavior = int + +const ( + system commandPathBehavior = iota + forceAbsolutePath + forceBasePath +) + +// fakeExec implements execer interface for mocking purposes. +type fakeExec struct { + // execOutput maps binary name and output that should be returned on RunCommand(). + // Map is also being used to check if a binary exist. Command line args are not supported. + execOutput map[string][]byte + // commandPathBehavior controls what kind of path will be returned from fakeExec.Command: + // * system just calls exec.Command + // * forceAbsolutePath guarantees that the returned cmd.Path will be absolute + // * forceBasePath guarantees that the returned cmd.Path will be just the binary name + commandPathBehavior commandPathBehavior +} + +func (f fakeExec) RunCommand(cmd string, _ ...string) ([]byte, error) { + out, found := f.execOutput[cmd] + if !found { + return nil, errors.New("binary not found") + } + + return out, nil +} + +func (f fakeExec) LookPath(path string) (string, error) { + if _, found := f.execOutput[path]; found { + return "", nil + } + return "", trace.NotFound("not found") +} + +func (f fakeExec) Command(name string, arg ...string) *exec.Cmd { + switch f.commandPathBehavior { + case system: + return exec.Command(name, arg...) + case forceAbsolutePath: + path, err := os.Getwd() + if err != nil { + panic(err) + } + + absolutePath := filepath.Join(path, name) + cmd := exec.Command(absolutePath, arg...) + + return cmd + case forceBasePath: + cmd := exec.Command(name, arg...) + cmd.Path = filepath.Base(cmd.Path) + return cmd + } + panic("Unknown commandPathBehavior") +} + +func TestCLICommandBuilderGetConnectCommand(t *testing.T) { + conf := &client.Config{ + HomePath: t.TempDir(), + Host: "localhost", + WebProxyAddr: "localhost", + SiteName: "db.example.com", + } + + tc, err := client.NewClient(conf) + require.NoError(t, err) + + profile := &client.ProfileStatus{ + Name: "example.com", + Username: "bob", + Dir: "/tmp", + } + + tests := []struct { + name string + opts []ConnectCommandFunc + dbProtocol string + databaseName string + execer *fakeExec + cmd []string + wantErr bool + }{ + { + name: "postgres", + dbProtocol: defaults.ProtocolPostgres, + databaseName: "mydb", + execer: &fakeExec{}, + cmd: []string{"psql", + "postgres://myUser@localhost:12345/mydb?sslrootcert=/tmp/keys/example.com/cas/root.pem&" + + "sslcert=/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem&" + + "sslkey=/tmp/keys/example.com/bob&sslmode=verify-full"}, + wantErr: false, + }, + { + name: "postgres no TLS", + dbProtocol: defaults.ProtocolPostgres, + databaseName: "mydb", + opts: []ConnectCommandFunc{WithNoTLS()}, + execer: &fakeExec{}, + cmd: []string{"psql", + "postgres://myUser@localhost:12345/mydb"}, + wantErr: false, + }, + { + name: "postgres print format", + dbProtocol: defaults.ProtocolPostgres, + databaseName: "mydb", + opts: []ConnectCommandFunc{WithPrintFormat()}, + execer: &fakeExec{}, + cmd: []string{"psql", + "\"postgres://myUser@localhost:12345/mydb?sslrootcert=/tmp/keys/example.com/cas/root.pem&" + + "sslcert=/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem&" + + "sslkey=/tmp/keys/example.com/bob&sslmode=verify-full\""}, + wantErr: false, + }, + { + name: "cockroach", + dbProtocol: defaults.ProtocolCockroachDB, + databaseName: "mydb", + execer: &fakeExec{ + execOutput: map[string][]byte{ + "cockroach": []byte(""), + }, + }, + cmd: []string{"cockroach", "sql", "--url", + "postgres://myUser@localhost:12345/mydb?sslrootcert=/tmp/keys/example.com/cas/root.pem&" + + "sslcert=/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem&" + + "sslkey=/tmp/keys/example.com/bob&sslmode=verify-full"}, + wantErr: false, + }, + { + name: "cockroach no TLS", + dbProtocol: defaults.ProtocolCockroachDB, + databaseName: "mydb", + opts: []ConnectCommandFunc{WithNoTLS()}, + execer: &fakeExec{ + execOutput: map[string][]byte{ + "cockroach": []byte(""), + }, + }, + cmd: []string{"cockroach", "sql", "--url", + "postgres://myUser@localhost:12345/mydb"}, + wantErr: false, + }, + { + name: "cockroach print format", + dbProtocol: defaults.ProtocolCockroachDB, + databaseName: "mydb", + opts: []ConnectCommandFunc{WithPrintFormat()}, + execer: &fakeExec{ + execOutput: map[string][]byte{ + "cockroach": []byte(""), + }, + }, + cmd: []string{"cockroach", "sql", "--url", + "\"postgres://myUser@localhost:12345/mydb?sslrootcert=/tmp/keys/example.com/cas/root.pem&" + + "sslcert=/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem&" + + "sslkey=/tmp/keys/example.com/bob&sslmode=verify-full\""}, + wantErr: false, + }, + { + name: "cockroach psql fallback", + dbProtocol: defaults.ProtocolCockroachDB, + databaseName: "mydb", + execer: &fakeExec{}, + cmd: []string{"psql", + "postgres://myUser@localhost:12345/mydb?sslrootcert=/tmp/keys/example.com/cas/root.pem&" + + "sslcert=/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem&" + + "sslkey=/tmp/keys/example.com/bob&sslmode=verify-full"}, + wantErr: false, + }, + { + name: "mariadb", + dbProtocol: defaults.ProtocolMySQL, + databaseName: "mydb", + execer: &fakeExec{ + execOutput: map[string][]byte{ + "mariadb": []byte(""), + }, + }, + cmd: []string{"mariadb", + "--user", "myUser", + "--database", "mydb", + "--port", "12345", + "--host", "localhost", + "--protocol", "TCP", + "--ssl-key", "/tmp/keys/example.com/bob", + "--ssl-ca", "/tmp/keys/example.com/cas/root.pem", + "--ssl-cert", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", + "--ssl-verify-server-cert"}, + wantErr: false, + }, + { + name: "mariadb no TLS", + dbProtocol: defaults.ProtocolMySQL, + databaseName: "mydb", + opts: []ConnectCommandFunc{WithNoTLS()}, + execer: &fakeExec{ + execOutput: map[string][]byte{ + "mariadb": []byte(""), + }, + }, + cmd: []string{"mariadb", + "--user", "myUser", + "--database", "mydb", + "--port", "12345", + "--host", "localhost", + "--protocol", "TCP"}, + wantErr: false, + }, + { + name: "mysql by mariadb", + dbProtocol: defaults.ProtocolMySQL, + databaseName: "mydb", + execer: &fakeExec{ + execOutput: map[string][]byte{ + "mysql": []byte("mysql Ver 15.1 Distrib 10.3.32-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2"), + }, + }, + cmd: []string{"mysql", + "--user", "myUser", + "--database", "mydb", + "--port", "12345", + "--host", "localhost", + "--protocol", "TCP", + "--ssl-key", "/tmp/keys/example.com/bob", + "--ssl-ca", "/tmp/keys/example.com/cas/root.pem", + "--ssl-cert", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", + "--ssl-verify-server-cert"}, + wantErr: false, + }, + { + name: "mysql by oracle", + dbProtocol: defaults.ProtocolMySQL, + databaseName: "mydb", + execer: &fakeExec{ + execOutput: map[string][]byte{ + "mysql": []byte("Ver 8.0.27-0ubuntu0.20.04.1 for Linux on x86_64 ((Ubuntu))"), + }, + }, + cmd: []string{"mysql", + "--defaults-group-suffix=_db.example.com-mysql", + "--user", "myUser", + "--database", "mydb", + "--port", "12345", + "--host", "localhost", + "--protocol", "TCP"}, + wantErr: false, + }, + { + name: "mysql no TLS", + dbProtocol: defaults.ProtocolMySQL, + databaseName: "mydb", + opts: []ConnectCommandFunc{WithNoTLS()}, + execer: &fakeExec{ + execOutput: map[string][]byte{ + "mysql": []byte("Ver 8.0.27-0ubuntu0.20.04.1 for Linux on x86_64 ((Ubuntu))"), + }, + }, + cmd: []string{"mysql", + "--user", "myUser", + "--database", "mydb", + "--port", "12345", + "--host", "localhost", + "--protocol", "TCP"}, + wantErr: false, + }, + { + name: "no mysql nor mariadb", + dbProtocol: defaults.ProtocolMySQL, + databaseName: "mydb", + execer: &fakeExec{ + execOutput: map[string][]byte{}, + }, + cmd: []string{}, + wantErr: true, + }, + { + name: "no mysql nor mariadb with no TLS and tolerateMissingCLIClient", + dbProtocol: defaults.ProtocolMySQL, + databaseName: "mydb", + opts: []ConnectCommandFunc{WithNoTLS(), WithTolerateMissingCLIClient()}, + execer: &fakeExec{ + execOutput: map[string][]byte{}, + }, + cmd: []string{"mysql", + "--user", "myUser", + "--database", "mydb", + "--port", "12345", + "--host", "localhost", + "--protocol", "TCP"}, + wantErr: false, + }, + { + name: "mongodb (legacy)", + dbProtocol: defaults.ProtocolMongoDB, + databaseName: "mydb", + execer: &fakeExec{ + execOutput: map[string][]byte{}, + }, + cmd: []string{"mongo", + "--host", "localhost", + "--port", "12345", + "--ssl", + "--sslPEMKeyFile", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", + "mydb"}, + wantErr: false, + }, + { + name: "mongodb no TLS", + dbProtocol: defaults.ProtocolMongoDB, + databaseName: "mydb", + opts: []ConnectCommandFunc{WithNoTLS()}, + execer: &fakeExec{ + execOutput: map[string][]byte{}, + }, + cmd: []string{"mongo", + "--host", "localhost", + "--port", "12345", + "mydb"}, + wantErr: false, + }, + { + name: "mongosh", + dbProtocol: defaults.ProtocolMongoDB, + databaseName: "mydb", + execer: &fakeExec{ + execOutput: map[string][]byte{ + "mongosh": []byte("1.1.6"), + }, + }, + cmd: []string{"mongosh", + "--host", "localhost", + "--port", "12345", + "--tls", + "--tlsCertificateKeyFile", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", + "--tlsUseSystemCA", + "mydb"}, + }, + { + name: "mongosh no TLS", + dbProtocol: defaults.ProtocolMongoDB, + databaseName: "mydb", + opts: []ConnectCommandFunc{WithNoTLS()}, + execer: &fakeExec{ + execOutput: map[string][]byte{ + "mongosh": []byte("1.1.6"), + }, + }, + cmd: []string{"mongosh", + "--host", "localhost", + "--port", "12345", + "mydb"}, + }, + { + name: "sqlserver", + dbProtocol: defaults.ProtocolSQLServer, + databaseName: "mydb", + execer: &fakeExec{}, + cmd: []string{mssqlBin, + "-S", "localhost,12345", + "-U", "myUser", + "-P", fixtures.UUID, + "-d", "mydb", + }, + wantErr: false, + }, + { + name: "redis-cli", + dbProtocol: defaults.ProtocolRedis, + execer: &fakeExec{}, + cmd: []string{"redis-cli", + "-h", "localhost", + "-p", "12345", + "--tls", + "--key", "/tmp/keys/example.com/bob", + "--cert", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem"}, + wantErr: false, + }, + { + name: "redis-cli with db", + dbProtocol: defaults.ProtocolRedis, + databaseName: "2", + execer: &fakeExec{}, + cmd: []string{"redis-cli", + "-h", "localhost", + "-p", "12345", + "--tls", + "--key", "/tmp/keys/example.com/bob", + "--cert", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", + "-n", "2"}, + wantErr: false, + }, + { + name: "redis-cli no TLS", + dbProtocol: defaults.ProtocolRedis, + opts: []ConnectCommandFunc{WithNoTLS()}, + execer: &fakeExec{}, + cmd: []string{"redis-cli", + "-h", "localhost", + "-p", "12345"}, + wantErr: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + database := &tlsca.RouteToDatabase{ + Protocol: tt.dbProtocol, + Database: tt.databaseName, + Username: "myUser", + ServiceName: "mysql", + } + + opts := append([]ConnectCommandFunc{ + WithLocalProxy("localhost", 12345, ""), + }, tt.opts...) + + c := NewCmdBuilder(tc, profile, database, "root", opts...) + c.uid = utils.NewFakeUID() + c.exe = tt.execer + got, err := c.GetConnectCommand() + if tt.wantErr { + if err == nil { + t.Errorf("getConnectCommand() should return an error, but it didn't") + } + return + } + + require.NoError(t, err) + require.Equal(t, tt.cmd, got.Args) + }) + } +} + +func TestGetConnectCommandNoAbsPathConvertsAbsolutePathToRelative(t *testing.T) { + conf := &client.Config{ + HomePath: t.TempDir(), + Host: "localhost", + WebProxyAddr: "localhost", + SiteName: "db.example.com", + } + + tc, err := client.NewClient(conf) + require.NoError(t, err) + + profile := &client.ProfileStatus{ + Name: "example.com", + Username: "bob", + Dir: "/tmp", + } + + database := &tlsca.RouteToDatabase{ + Protocol: defaults.ProtocolPostgres, + Database: "mydb", + Username: "myUser", + ServiceName: "postgres", + } + + opts := []ConnectCommandFunc{ + WithLocalProxy("localhost", 12345, ""), + WithNoTLS(), + } + + c := NewCmdBuilder(tc, profile, database, "root", opts...) + c.uid = utils.NewFakeUID() + c.exe = &fakeExec{commandPathBehavior: forceAbsolutePath} + + got, err := c.GetConnectCommandNoAbsPath() + require.NoError(t, err) + require.Equal(t, "psql postgres://myUser@localhost:12345/mydb", got.String()) +} + +func TestGetConnectCommandNoAbsPathIsNoopWhenGivenRelativePath(t *testing.T) { + conf := &client.Config{ + HomePath: t.TempDir(), + Host: "localhost", + WebProxyAddr: "localhost", + SiteName: "db.example.com", + } + + tc, err := client.NewClient(conf) + require.NoError(t, err) + + profile := &client.ProfileStatus{ + Name: "example.com", + Username: "bob", + Dir: "/tmp", + } + + database := &tlsca.RouteToDatabase{ + Protocol: defaults.ProtocolPostgres, + Database: "mydb", + Username: "myUser", + ServiceName: "postgres", + } + + opts := []ConnectCommandFunc{ + WithLocalProxy("localhost", 12345, ""), + WithNoTLS(), + } + + c := NewCmdBuilder(tc, profile, database, "root", opts...) + c.uid = utils.NewFakeUID() + c.exe = &fakeExec{commandPathBehavior: forceBasePath} + + got, err := c.GetConnectCommandNoAbsPath() + require.NoError(t, err) + require.Equal(t, "psql postgres://myUser@localhost:12345/mydb", got.String()) +} diff --git a/lib/client/db/postgres/connstring.go b/lib/client/db/postgres/connstring.go index b3dcd91664eb1..91bb54e5b3213 100644 --- a/lib/client/db/postgres/connstring.go +++ b/lib/client/db/postgres/connstring.go @@ -27,7 +27,7 @@ import ( ) // GetConnString returns formatted Postgres connection string for the profile. -func GetConnString(c *profile.ConnectProfile, noTLS bool) string { +func GetConnString(c *profile.ConnectProfile, noTLS bool, printFormat bool) string { connStr := "postgres://" if c.User != "" { // Username may contain special characters in which case it should @@ -54,5 +54,12 @@ func GetConnString(c *profile.ConnectProfile, noTLS bool) string { params = append(params, fmt.Sprintf("sslmode=%v", SSLModeVerifyFull)) } - return fmt.Sprintf("%v?%v", connStr, strings.Join(params, "&")) + connStr = fmt.Sprintf("%v?%v", connStr, strings.Join(params, "&")) + + // The printed connection string may get copy-pasted for execution. Add + // quotes to avoid "&" getting interpreted by terminals. + if printFormat { + connStr = fmt.Sprintf(`"%s"`, connStr) + } + return connStr } diff --git a/lib/client/db/postgres/connstring_test.go b/lib/client/db/postgres/connstring_test.go index 9b7e4031d5f5c..567c67beb47fa 100644 --- a/lib/client/db/postgres/connstring_test.go +++ b/lib/client/db/postgres/connstring_test.go @@ -84,7 +84,7 @@ func TestConnString(t *testing.T) { CACertPath: caPath, CertPath: certPath, KeyPath: keyPath, - }, false)) + }, false, false)) }) } } diff --git a/lib/teleterm/api/proto/v1/gateway.proto b/lib/teleterm/api/proto/v1/gateway.proto index 8e8c079823751..605cbc44f32b5 100644 --- a/lib/teleterm/api/proto/v1/gateway.proto +++ b/lib/teleterm/api/proto/v1/gateway.proto @@ -41,12 +41,11 @@ message Gateway { string local_port = 6; // protocol is the gateway protocol string protocol = 7; - // insecure is whether to skip certificate validation - bool insecure = 8; - // ca_cert_path - string ca_cert_path = 9; - // cert_path - string cert_path = 10; - // key_path - string key_path = 11; + // cli_command is a command that the user can execute to connect to the resource within a CLI, + // if the given resource has a CLI client. + // + // Instead of generating those commands in in the frontend code, the tsh daemon returns them. + // This means that the Database Access team can add support for a new protocol and Teleterm will + // support it right away without any changes to Teleterm's code. + string cli_command = 8; } diff --git a/lib/teleterm/api/protogen/golang/v1/gateway.pb.go b/lib/teleterm/api/protogen/golang/v1/gateway.pb.go index 0fb29b0f9c8ae..cede18cd7e821 100644 --- a/lib/teleterm/api/protogen/golang/v1/gateway.pb.go +++ b/lib/teleterm/api/protogen/golang/v1/gateway.pb.go @@ -66,14 +66,13 @@ type Gateway struct { LocalPort string `protobuf:"bytes,6,opt,name=local_port,json=localPort,proto3" json:"local_port,omitempty"` // protocol is the gateway protocol Protocol string `protobuf:"bytes,7,opt,name=protocol,proto3" json:"protocol,omitempty"` - // insecure is whether to skip certificate validation - Insecure bool `protobuf:"varint,8,opt,name=insecure,proto3" json:"insecure,omitempty"` - // ca_cert_path - CaCertPath string `protobuf:"bytes,9,opt,name=ca_cert_path,json=caCertPath,proto3" json:"ca_cert_path,omitempty"` - // cert_path - CertPath string `protobuf:"bytes,10,opt,name=cert_path,json=certPath,proto3" json:"cert_path,omitempty"` - // key_path - KeyPath string `protobuf:"bytes,11,opt,name=key_path,json=keyPath,proto3" json:"key_path,omitempty"` + // cli_command is a command that the user can execute to connect to the resource within a CLI, + // if the given resource has a CLI client. + // + // Instead of generating those commands in in the frontend code, the tsh daemon returns them. + // This means that the Database Access team can add support for a new protocol and Teleterm will + // support it right away without any changes to Teleterm's code. + CliCommand string `protobuf:"bytes,8,opt,name=cli_command,json=cliCommand,proto3" json:"cli_command,omitempty"` } func (x *Gateway) Reset() { @@ -157,30 +156,9 @@ func (x *Gateway) GetProtocol() string { return "" } -func (x *Gateway) GetInsecure() bool { +func (x *Gateway) GetCliCommand() string { if x != nil { - return x.Insecure - } - return false -} - -func (x *Gateway) GetCaCertPath() string { - if x != nil { - return x.CaCertPath - } - return "" -} - -func (x *Gateway) GetCertPath() string { - if x != nil { - return x.CertPath - } - return "" -} - -func (x *Gateway) GetKeyPath() string { - if x != nil { - return x.KeyPath + return x.CliCommand } return "" } @@ -190,7 +168,7 @@ var File_v1_gateway_proto protoreflect.FileDescriptor var file_v1_gateway_proto_rawDesc = []byte{ 0x0a, 0x10, 0x76, 0x31, 0x2f, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x74, 0x65, 0x72, - 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x2e, 0x76, 0x31, 0x22, 0xd2, 0x02, 0x0a, 0x07, 0x47, 0x61, 0x74, + 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x2e, 0x76, 0x31, 0x22, 0xfd, 0x01, 0x0a, 0x07, 0x47, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x61, 0x72, @@ -204,18 +182,13 @@ var file_v1_gateway_proto_rawDesc = []byte{ 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x73, 0x65, 0x63, - 0x75, 0x72, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x69, 0x6e, 0x73, 0x65, 0x63, - 0x75, 0x72, 0x65, 0x12, 0x20, 0x0a, 0x0c, 0x63, 0x61, 0x5f, 0x63, 0x65, 0x72, 0x74, 0x5f, 0x70, - 0x61, 0x74, 0x68, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x61, 0x43, 0x65, 0x72, - 0x74, 0x50, 0x61, 0x74, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x65, 0x72, 0x74, 0x5f, 0x70, 0x61, - 0x74, 0x68, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x65, 0x72, 0x74, 0x50, 0x61, - 0x74, 0x68, 0x12, 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x0b, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x50, 0x61, 0x74, 0x68, 0x42, 0x33, 0x5a, - 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x76, - 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, - 0x72, 0x74, 0x2f, 0x6c, 0x69, 0x62, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2f, - 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x6c, 0x69, 0x5f, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6c, + 0x69, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x6c, 0x69, + 0x62, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/lib/teleterm/api/protogen/js/v1/gateway_pb.d.ts b/lib/teleterm/api/protogen/js/v1/gateway_pb.d.ts index bda2363d2182c..d9a2f919cbdf5 100644 --- a/lib/teleterm/api/protogen/js/v1/gateway_pb.d.ts +++ b/lib/teleterm/api/protogen/js/v1/gateway_pb.d.ts @@ -28,17 +28,8 @@ export class Gateway extends jspb.Message { getProtocol(): string; setProtocol(value: string): Gateway; - getInsecure(): boolean; - setInsecure(value: boolean): Gateway; - - getCaCertPath(): string; - setCaCertPath(value: string): Gateway; - - getCertPath(): string; - setCertPath(value: string): Gateway; - - getKeyPath(): string; - setKeyPath(value: string): Gateway; + getCliCommand(): string; + setCliCommand(value: string): Gateway; serializeBinary(): Uint8Array; @@ -60,9 +51,6 @@ export namespace Gateway { localAddress: string, localPort: string, protocol: string, - insecure: boolean, - caCertPath: string, - certPath: string, - keyPath: string, + cliCommand: string, } } diff --git a/lib/teleterm/api/protogen/js/v1/gateway_pb.js b/lib/teleterm/api/protogen/js/v1/gateway_pb.js index 049008c82c16b..d8cdbc84c661d 100644 --- a/lib/teleterm/api/protogen/js/v1/gateway_pb.js +++ b/lib/teleterm/api/protogen/js/v1/gateway_pb.js @@ -73,10 +73,7 @@ proto.teleport.terminal.v1.Gateway.toObject = function(includeInstance, msg) { localAddress: jspb.Message.getFieldWithDefault(msg, 5, ""), localPort: jspb.Message.getFieldWithDefault(msg, 6, ""), protocol: jspb.Message.getFieldWithDefault(msg, 7, ""), - insecure: jspb.Message.getBooleanFieldWithDefault(msg, 8, false), - caCertPath: jspb.Message.getFieldWithDefault(msg, 9, ""), - certPath: jspb.Message.getFieldWithDefault(msg, 10, ""), - keyPath: jspb.Message.getFieldWithDefault(msg, 11, "") + cliCommand: jspb.Message.getFieldWithDefault(msg, 8, "") }; if (includeInstance) { @@ -142,20 +139,8 @@ proto.teleport.terminal.v1.Gateway.deserializeBinaryFromReader = function(msg, r msg.setProtocol(value); break; case 8: - var value = /** @type {boolean} */ (reader.readBool()); - msg.setInsecure(value); - break; - case 9: - var value = /** @type {string} */ (reader.readString()); - msg.setCaCertPath(value); - break; - case 10: - var value = /** @type {string} */ (reader.readString()); - msg.setCertPath(value); - break; - case 11: var value = /** @type {string} */ (reader.readString()); - msg.setKeyPath(value); + msg.setCliCommand(value); break; default: reader.skipField(); @@ -235,31 +220,10 @@ proto.teleport.terminal.v1.Gateway.serializeBinaryToWriter = function(message, w f ); } - f = message.getInsecure(); - if (f) { - writer.writeBool( - 8, - f - ); - } - f = message.getCaCertPath(); + f = message.getCliCommand(); if (f.length > 0) { writer.writeString( - 9, - f - ); - } - f = message.getCertPath(); - if (f.length > 0) { - writer.writeString( - 10, - f - ); - } - f = message.getKeyPath(); - if (f.length > 0) { - writer.writeString( - 11, + 8, f ); } @@ -393,65 +357,11 @@ proto.teleport.terminal.v1.Gateway.prototype.setProtocol = function(value) { /** - * optional bool insecure = 8; - * @return {boolean} - */ -proto.teleport.terminal.v1.Gateway.prototype.getInsecure = function() { - return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 8, false)); -}; - - -/** - * @param {boolean} value - * @return {!proto.teleport.terminal.v1.Gateway} returns this - */ -proto.teleport.terminal.v1.Gateway.prototype.setInsecure = function(value) { - return jspb.Message.setProto3BooleanField(this, 8, value); -}; - - -/** - * optional string ca_cert_path = 9; - * @return {string} - */ -proto.teleport.terminal.v1.Gateway.prototype.getCaCertPath = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 9, "")); -}; - - -/** - * @param {string} value - * @return {!proto.teleport.terminal.v1.Gateway} returns this - */ -proto.teleport.terminal.v1.Gateway.prototype.setCaCertPath = function(value) { - return jspb.Message.setProto3StringField(this, 9, value); -}; - - -/** - * optional string cert_path = 10; - * @return {string} - */ -proto.teleport.terminal.v1.Gateway.prototype.getCertPath = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 10, "")); -}; - - -/** - * @param {string} value - * @return {!proto.teleport.terminal.v1.Gateway} returns this - */ -proto.teleport.terminal.v1.Gateway.prototype.setCertPath = function(value) { - return jspb.Message.setProto3StringField(this, 10, value); -}; - - -/** - * optional string key_path = 11; + * optional string cli_command = 8; * @return {string} */ -proto.teleport.terminal.v1.Gateway.prototype.getKeyPath = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 11, "")); +proto.teleport.terminal.v1.Gateway.prototype.getCliCommand = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 8, "")); }; @@ -459,8 +369,8 @@ proto.teleport.terminal.v1.Gateway.prototype.getKeyPath = function() { * @param {string} value * @return {!proto.teleport.terminal.v1.Gateway} returns this */ -proto.teleport.terminal.v1.Gateway.prototype.setKeyPath = function(value) { - return jspb.Message.setProto3StringField(this, 11, value); +proto.teleport.terminal.v1.Gateway.prototype.setCliCommand = function(value) { + return jspb.Message.setProto3StringField(this, 8, value); }; diff --git a/lib/teleterm/apiserver/handler/handler_gateways.go b/lib/teleterm/apiserver/handler/handler_gateways.go index 4741cd3fbd9ad..b212813e70c44 100644 --- a/lib/teleterm/apiserver/handler/handler_gateways.go +++ b/lib/teleterm/apiserver/handler/handler_gateways.go @@ -75,9 +75,6 @@ func newAPIGateway(gateway *gateway.Gateway) *api.Gateway { Protocol: gateway.Protocol, LocalAddress: gateway.LocalAddress, LocalPort: gateway.LocalPort, - CaCertPath: gateway.CACertPath, - CertPath: gateway.CertPath, - KeyPath: gateway.KeyPath, - Insecure: gateway.Insecure, + CliCommand: gateway.CLICommand, } } diff --git a/lib/teleterm/clusters/cluster_gateways.go b/lib/teleterm/clusters/cluster_gateways.go index 940b65bb9c5a0..64cc262219191 100644 --- a/lib/teleterm/clusters/cluster_gateways.go +++ b/lib/teleterm/clusters/cluster_gateways.go @@ -18,8 +18,11 @@ package clusters import ( "context" + "os/exec" + "github.com/gravitational/teleport/lib/client/db/dbcmd" "github.com/gravitational/teleport/lib/teleterm/gateway" + "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/trace" ) @@ -51,7 +54,6 @@ func (c *Cluster) CreateGateway(ctx context.Context, params CreateGatewayParams) TargetName: db.GetName(), Protocol: db.GetProtocol(), KeyPath: c.status.KeyPath(), - CACertPath: c.status.CACertPathForCluster(c.Name), CertPath: c.status.DatabaseCertPathForCluster("", db.GetName()), Insecure: c.clusterClient.InsecureSkipVerify, WebProxyAddr: c.clusterClient.WebProxyAddr, @@ -61,5 +63,32 @@ func (c *Cluster) CreateGateway(ctx context.Context, params CreateGatewayParams) return nil, trace.Wrap(err) } + cliCommand, err := buildCLICommand(c, gw) + if err != nil { + return nil, trace.Wrap(err) + } + gw.CLICommand = cliCommand.String() + return gw, nil } + +func buildCLICommand(c *Cluster, gw *gateway.Gateway) (*exec.Cmd, error) { + routeToDb := tlsca.RouteToDatabase{ + ServiceName: gw.TargetName, + Protocol: gw.Protocol, + Username: gw.TargetUser, + } + + cmd, err := dbcmd.NewCmdBuilder(c.clusterClient, &c.status, &routeToDb, c.URI.GetRootClusterName(), + dbcmd.WithLogger(gw.Log), + dbcmd.WithLocalProxy(gw.LocalAddress, gw.LocalPortInt(), ""), + dbcmd.WithNoTLS(), + dbcmd.WithTolerateMissingCLIClient(), + ).GetConnectCommandNoAbsPath() + + if err != nil { + return nil, trace.Wrap(err) + } + + return cmd, nil +} diff --git a/lib/teleterm/gateway/config.go b/lib/teleterm/gateway/config.go index 74fb1bb27db54..a36f6de3d95bc 100644 --- a/lib/teleterm/gateway/config.go +++ b/lib/teleterm/gateway/config.go @@ -25,7 +25,7 @@ import ( "github.com/sirupsen/logrus" ) -// CreateGatewayParams describes gateway parameters +// Config describes gateway configuration type Config struct { // URI is the gateway URI URI uri.ResourceURI @@ -42,8 +42,6 @@ type Config struct { LocalAddress string // Protocol is the gateway protocol Protocol string - // CACertPath - CACertPath string // CertPath CertPath string // KeyPath @@ -53,7 +51,7 @@ type Config struct { // WebProxyAddr WebProxyAddr string // Log is a component logger - Log logrus.FieldLogger + Log *logrus.Entry } // CheckAndSetDefaults checks and sets the defaults diff --git a/lib/teleterm/gateway/gateway.go b/lib/teleterm/gateway/gateway.go index af0d64c9c9ccd..0b740f71604fa 100644 --- a/lib/teleterm/gateway/gateway.go +++ b/lib/teleterm/gateway/gateway.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "net" + "strconv" alpn "github.com/gravitational/teleport/lib/srv/alpnproxy" alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common" @@ -112,9 +113,21 @@ func (g *Gateway) Open() { }() } +// LocalPortInt returns the port of a gateway as an integer rather than a string. +func (g *Gateway) LocalPortInt() int { + // Ignoring the error here as Teleterm doesn't allow the user to pick the value for the port, so + // it'll always be a random integer value, not a service name that needs actual lookup. + // For more details, see https://stackoverflow.com/questions/47992477/why-is-port-a-string-and-not-an-integer + port, _ := strconv.Atoi(g.LocalPort) + return port +} + // Gateway describes local proxy that creates a gateway to the remote Teleport resource. type Gateway struct { Config + // Set by the cluster when running clusters.Cluster.CreateGateway. + // We can't set here inside New as dbcmd.NewCmdBuilder needs info from the cluster. + CLICommand string localProxy *alpn.LocalProxy // closeContext and closeCancel are used to signal to any waiting goroutines diff --git a/tool/tsh/db.go b/tool/tsh/db.go index d894575ba5c37..09ce7a2c36598 100644 --- a/tool/tsh/db.go +++ b/tool/tsh/db.go @@ -29,6 +29,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/client" dbprofile "github.com/gravitational/teleport/lib/client/db" + "github.com/gravitational/teleport/lib/client/db/dbcmd" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/tlsca" @@ -301,7 +302,10 @@ func onDatabaseConfig(cf *CLIConf) error { format := strings.ToLower(cf.Format) switch format { case dbFormatCommand: - cmd, err := newCmdBuilder(tc, profile, database, rootCluster).getConnectCommand() + cmd, err := dbcmd.NewCmdBuilder(tc, profile, database, rootCluster, + dbcmd.WithPrintFormat(), + dbcmd.WithLogger(log), + ).GetConnectCommand() if err != nil { return trace.Wrap(err) } @@ -360,11 +364,11 @@ func serializeDatabaseConfig(configInfo *dbConfigInfo, format string) (string, e // maybeStartLocalProxy starts local TLS ALPN proxy if needed depending on the // connection scenario and returns a list of options to use in the connect // command. -func maybeStartLocalProxy(cf *CLIConf, tc *client.TeleportClient, profile *client.ProfileStatus, db *tlsca.RouteToDatabase, cluster string) ([]ConnectCommandFunc, error) { +func maybeStartLocalProxy(cf *CLIConf, tc *client.TeleportClient, profile *client.ProfileStatus, db *tlsca.RouteToDatabase, cluster string) ([]dbcmd.ConnectCommandFunc, error) { // Local proxy is started if TLS routing is enabled, or if this is a SQL // Server connection which always requires a local proxy. if !tc.TLSRoutingEnabled && db.Protocol != defaults.ProtocolSQLServer { - return []ConnectCommandFunc{}, nil + return []dbcmd.ConnectCommandFunc{}, nil } listener, err := net.Listen("tcp", "localhost:0") @@ -407,8 +411,8 @@ func maybeStartLocalProxy(cf *CLIConf, tc *client.TeleportClient, profile *clien // certificate's DNS names. As such, connecting to 127.0.0.1 will fail // validation, so connect to localhost. host := "localhost" - return []ConnectCommandFunc{ - WithLocalProxy(host, addr.Port(0), profile.CACertPathForCluster(cluster)), + return []dbcmd.ConnectCommandFunc{ + dbcmd.WithLocalProxy(host, addr.Port(0), profile.CACertPathForCluster(cluster)), }, nil } @@ -450,7 +454,8 @@ func onDatabaseConnect(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - cmd, err := newCmdBuilder(tc, profile, database, rootClusterName, opts...).getConnectCommand() + opts = append(opts, dbcmd.WithLogger(log)) + cmd, err := dbcmd.NewCmdBuilder(tc, profile, database, rootClusterName, opts...).GetConnectCommand() if err != nil { return trace.Wrap(err) } @@ -654,34 +659,6 @@ func pickActiveDatabase(cf *CLIConf) (*tlsca.RouteToDatabase, error) { return nil, trace.NotFound("Not logged into database %q", name) } -type connectionCommandOpts struct { - localProxyPort int - localProxyHost string - caPath string - noTLS bool -} - -type ConnectCommandFunc func(*connectionCommandOpts) - -func WithLocalProxy(host string, port int, caPath string) ConnectCommandFunc { - return func(opts *connectionCommandOpts) { - opts.localProxyPort = port - opts.localProxyHost = host - opts.caPath = caPath - } -} - -// WithNoTLS is the connect command option that makes the command connect -// without TLS. -// -// It is used when connecting through the local proxy that was started in -// mutual TLS mode (i.e. with a client certificate). -func WithNoTLS() ConnectCommandFunc { - return func(opts *connectionCommandOpts) { - opts.noTLS = true - } -} - func formatDatabaseListCommand(clusterFlag string) string { if clusterFlag == "" { return "tsh db ls" diff --git a/tool/tsh/db_test.go b/tool/tsh/db_test.go index 10bfea69a10d1..542b466916377 100644 --- a/tool/tsh/db_test.go +++ b/tool/tsh/db_test.go @@ -21,7 +21,6 @@ import ( "crypto/rand" "crypto/rsa" "encoding/pem" - "errors" "io/ioutil" "os" "path/filepath" @@ -326,361 +325,3 @@ func decodePEM(pemPath string) (certs []pem.Block, keys []pem.Block, err error) } return certs, keys, nil } - -// fakeExec implements execer interface for mocking purposes. -type fakeExec struct { - // execOutput maps binary name and output that should be returned on RunCommand(). - // Map is also being used to check if a binary exist. Command line args are not supported. - execOutput map[string][]byte -} - -func (f fakeExec) RunCommand(cmd string, _ ...string) ([]byte, error) { - out, found := f.execOutput[cmd] - if !found { - return nil, errors.New("binary not found") - } - - return out, nil -} - -func (f fakeExec) LookPath(path string) (string, error) { - if _, found := f.execOutput[path]; found { - return "", nil - } - return "", trace.NotFound("not found") -} - -func TestCliCommandBuilderGetConnectCommand(t *testing.T) { - conf := &CLIConf{ - HomePath: t.TempDir(), - Proxy: "proxy", - UserHost: "localhost", - SiteName: "db.example.com", - } - - tc, err := makeClient(conf, true) - require.NoError(t, err) - - profile := &client.ProfileStatus{ - Name: "example.com", - Username: "bob", - Dir: "/tmp", - } - - tests := []struct { - name string - dbProtocol string - databaseName string - execer *fakeExec - cmd []string - noTLS bool - wantErr bool - }{ - { - name: "postgres", - dbProtocol: defaults.ProtocolPostgres, - databaseName: "mydb", - cmd: []string{"psql", - "postgres://myUser@localhost:12345/mydb?sslrootcert=/tmp/keys/example.com/cas/root.pem&" + - "sslcert=/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem&" + - "sslkey=/tmp/keys/example.com/bob&sslmode=verify-full"}, - wantErr: false, - }, - { - name: "postgres no TLS", - dbProtocol: defaults.ProtocolPostgres, - databaseName: "mydb", - noTLS: true, - cmd: []string{"psql", - "postgres://myUser@localhost:12345/mydb"}, - wantErr: false, - }, - { - name: "cockroach", - dbProtocol: defaults.ProtocolCockroachDB, - databaseName: "mydb", - execer: &fakeExec{ - execOutput: map[string][]byte{ - "cockroach": []byte(""), - }, - }, - cmd: []string{"cockroach", "sql", "--url", - "postgres://myUser@localhost:12345/mydb?sslrootcert=/tmp/keys/example.com/cas/root.pem&" + - "sslcert=/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem&" + - "sslkey=/tmp/keys/example.com/bob&sslmode=verify-full"}, - wantErr: false, - }, - { - name: "cockroach no TLS", - dbProtocol: defaults.ProtocolCockroachDB, - databaseName: "mydb", - noTLS: true, - execer: &fakeExec{ - execOutput: map[string][]byte{ - "cockroach": []byte(""), - }, - }, - cmd: []string{"cockroach", "sql", "--url", - "postgres://myUser@localhost:12345/mydb"}, - wantErr: false, - }, - { - name: "cockroach psql fallback", - dbProtocol: defaults.ProtocolCockroachDB, - databaseName: "mydb", - execer: &fakeExec{}, - cmd: []string{"psql", - "postgres://myUser@localhost:12345/mydb?sslrootcert=/tmp/keys/example.com/cas/root.pem&" + - "sslcert=/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem&" + - "sslkey=/tmp/keys/example.com/bob&sslmode=verify-full"}, - wantErr: false, - }, - { - name: "mariadb", - dbProtocol: defaults.ProtocolMySQL, - databaseName: "mydb", - execer: &fakeExec{ - execOutput: map[string][]byte{ - "mariadb": []byte(""), - }, - }, - cmd: []string{"mariadb", - "--user", "myUser", - "--database", "mydb", - "--port", "12345", - "--host", "localhost", - "--protocol", "TCP", - "--ssl-key", "/tmp/keys/example.com/bob", - "--ssl-ca", "/tmp/keys/example.com/cas/root.pem", - "--ssl-cert", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", - "--ssl-verify-server-cert"}, - wantErr: false, - }, - { - name: "mariadb no TLS", - dbProtocol: defaults.ProtocolMySQL, - databaseName: "mydb", - noTLS: true, - execer: &fakeExec{ - execOutput: map[string][]byte{ - "mariadb": []byte(""), - }, - }, - cmd: []string{"mariadb", - "--user", "myUser", - "--database", "mydb", - "--port", "12345", - "--host", "localhost", - "--protocol", "TCP"}, - wantErr: false, - }, - { - name: "mysql by mariadb", - dbProtocol: defaults.ProtocolMySQL, - databaseName: "mydb", - execer: &fakeExec{ - execOutput: map[string][]byte{ - "mysql": []byte("mysql Ver 15.1 Distrib 10.3.32-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2"), - }, - }, - cmd: []string{"mysql", - "--user", "myUser", - "--database", "mydb", - "--port", "12345", - "--host", "localhost", - "--protocol", "TCP", - "--ssl-key", "/tmp/keys/example.com/bob", - "--ssl-ca", "/tmp/keys/example.com/cas/root.pem", - "--ssl-cert", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", - "--ssl-verify-server-cert"}, - wantErr: false, - }, - { - name: "mysql by oracle", - dbProtocol: defaults.ProtocolMySQL, - databaseName: "mydb", - execer: &fakeExec{ - execOutput: map[string][]byte{ - "mysql": []byte("Ver 8.0.27-0ubuntu0.20.04.1 for Linux on x86_64 ((Ubuntu))"), - }, - }, - cmd: []string{"mysql", - "--defaults-group-suffix=_db.example.com-mysql", - "--user", "myUser", - "--database", "mydb", - "--port", "12345", - "--host", "localhost", - "--protocol", "TCP"}, - wantErr: false, - }, - { - name: "mysql no TLS", - dbProtocol: defaults.ProtocolMySQL, - databaseName: "mydb", - noTLS: true, - execer: &fakeExec{ - execOutput: map[string][]byte{ - "mysql": []byte("Ver 8.0.27-0ubuntu0.20.04.1 for Linux on x86_64 ((Ubuntu))"), - }, - }, - cmd: []string{"mysql", - "--user", "myUser", - "--database", "mydb", - "--port", "12345", - "--host", "localhost", - "--protocol", "TCP"}, - wantErr: false, - }, - { - name: "no mysql nor mariadb", - dbProtocol: defaults.ProtocolMySQL, - databaseName: "mydb", - execer: &fakeExec{ - execOutput: map[string][]byte{}, - }, - cmd: []string{}, - wantErr: true, - }, - { - name: "mongodb (legacy)", - dbProtocol: defaults.ProtocolMongoDB, - databaseName: "mydb", - execer: &fakeExec{ - execOutput: map[string][]byte{}, - }, - cmd: []string{"mongo", - "--host", "localhost", - "--port", "12345", - "--ssl", - "--sslPEMKeyFile", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", - "mydb"}, - wantErr: false, - }, - { - name: "mongodb no TLS", - dbProtocol: defaults.ProtocolMongoDB, - databaseName: "mydb", - noTLS: true, - execer: &fakeExec{ - execOutput: map[string][]byte{}, - }, - cmd: []string{"mongo", - "--host", "localhost", - "--port", "12345", - "mydb"}, - wantErr: false, - }, - { - name: "mongosh", - dbProtocol: defaults.ProtocolMongoDB, - databaseName: "mydb", - execer: &fakeExec{ - execOutput: map[string][]byte{ - "mongosh": []byte("1.1.6"), - }, - }, - cmd: []string{"mongosh", - "--host", "localhost", - "--port", "12345", - "--tls", - "--tlsCertificateKeyFile", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", - "--tlsUseSystemCA", - "mydb"}, - }, - { - name: "mongosh no TLS", - dbProtocol: defaults.ProtocolMongoDB, - databaseName: "mydb", - noTLS: true, - execer: &fakeExec{ - execOutput: map[string][]byte{ - "mongosh": []byte("1.1.6"), - }, - }, - cmd: []string{"mongosh", - "--host", "localhost", - "--port", "12345", - "mydb"}, - }, - { - name: "sqlserver", - dbProtocol: defaults.ProtocolSQLServer, - databaseName: "mydb", - cmd: []string{mssqlBin, - "-S", "localhost,12345", - "-U", "myUser", - "-P", fixtures.UUID, - "-d", "mydb", - }, - wantErr: false, - }, - { - name: "redis-cli", - dbProtocol: defaults.ProtocolRedis, - cmd: []string{"redis-cli", - "-h", "localhost", - "-p", "12345", - "--tls", - "--key", "/tmp/keys/example.com/bob", - "--cert", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem"}, - wantErr: false, - }, - { - name: "redis-cli with db", - dbProtocol: defaults.ProtocolRedis, - databaseName: "2", - cmd: []string{"redis-cli", - "-h", "localhost", - "-p", "12345", - "--tls", - "--key", "/tmp/keys/example.com/bob", - "--cert", "/tmp/keys/example.com/bob-db/db.example.com/mysql-x509.pem", - "-n", "2"}, - wantErr: false, - }, - { - name: "redis-cli no TLS", - dbProtocol: defaults.ProtocolRedis, - noTLS: true, - cmd: []string{"redis-cli", - "-h", "localhost", - "-p", "12345"}, - wantErr: false, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - database := &tlsca.RouteToDatabase{ - Protocol: tt.dbProtocol, - Database: tt.databaseName, - Username: "myUser", - ServiceName: "mysql", - } - - opts := []ConnectCommandFunc{ - WithLocalProxy("localhost", 12345, ""), - } - if tt.noTLS { - opts = append(opts, WithNoTLS()) - } - - c := newCmdBuilder(tc, profile, database, "root", opts...) - c.uid = utils.NewFakeUID() - c.exe = tt.execer - got, err := c.getConnectCommand() - if tt.wantErr { - if err == nil { - t.Errorf("getConnectCommand() should return an error, but it didn't") - } - return - } - - require.NoError(t, err) - require.Equal(t, tt.cmd, got.Args) - }) - } -} diff --git a/tool/tsh/proxy.go b/tool/tsh/proxy.go index ea799e6e2618c..c2b066dc70d04 100644 --- a/tool/tsh/proxy.go +++ b/tool/tsh/proxy.go @@ -32,6 +32,7 @@ import ( "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/utils/keypaths" libclient "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/client/db/dbcmd" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/srv/alpnproxy" alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common" @@ -203,9 +204,11 @@ func onProxyCommandDB(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - cmd, err := newCmdBuilder(client, profile, database, cf.SiteName, - WithLocalProxy("localhost", addr.Port(0), ""), - WithNoTLS()).getConnectCommand() + cmd, err := dbcmd.NewCmdBuilder(client, profile, database, cf.SiteName, + dbcmd.WithLocalProxy("localhost", addr.Port(0), ""), + dbcmd.WithNoTLS(), + dbcmd.WithLogger(log), + ).GetConnectCommand() if err != nil { return trace.Wrap(err) }