mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-01 13:39:37 +08:00
* Support App access behind load balancer * revert unecessary change * fix lint * Fix UT * minor refactor
1274 lines
39 KiB
Go
1274 lines
39 KiB
Go
/*
|
|
Copyright 2020-2021 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 main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"text/template"
|
|
|
|
"github.com/ghodss/yaml"
|
|
"github.com/gravitational/trace"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
oteltrace "go.opentelemetry.io/otel/trace"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/gravitational/teleport"
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/api/utils/keys"
|
|
"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/srv/alpnproxy"
|
|
"github.com/gravitational/teleport/lib/srv/alpnproxy/common"
|
|
"github.com/gravitational/teleport/lib/srv/db/common/role"
|
|
"github.com/gravitational/teleport/lib/tlsca"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
)
|
|
|
|
// onListDatabases implements "tsh db ls" command.
|
|
func onListDatabases(cf *CLIConf) error {
|
|
if cf.ListAll {
|
|
return trace.Wrap(listDatabasesAllClusters(cf))
|
|
}
|
|
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
profile, err := tc.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
var proxy *client.ProxyClient
|
|
err = client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
proxy, err = tc.ConnectToProxy(cf.Context)
|
|
return trace.Wrap(err)
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
defer proxy.Close()
|
|
|
|
databases, err := proxy.FindDatabasesByFiltersForCluster(cf.Context, *tc.DefaultResourceFilter(), tc.SiteName)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
roleSet, err := fetchRoleSetForCluster(cf.Context, profile, proxy, tc.SiteName)
|
|
if err != nil {
|
|
log.Debugf("Failed to fetch user roles: %v.", err)
|
|
}
|
|
|
|
activeDatabases, err := profile.DatabasesForCluster(tc.SiteName)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
sort.Sort(types.Databases(databases))
|
|
return trace.Wrap(showDatabases(cf.Stdout(), cf.SiteName, databases, activeDatabases, roleSet, cf.Format, cf.Verbose))
|
|
}
|
|
|
|
func fetchRoleSetForCluster(ctx context.Context, profile *client.ProfileStatus, proxy *client.ProxyClient, clusterName string) (services.RoleSet, error) {
|
|
cluster, err := proxy.ConnectToCluster(ctx, clusterName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
defer cluster.Close()
|
|
|
|
roleSet, err := services.FetchAllClusterRoles(ctx, cluster, profile.Roles, profile.Traits)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return roleSet, nil
|
|
}
|
|
|
|
type databaseListing struct {
|
|
Proxy string `json:"proxy"`
|
|
Cluster string `json:"cluster"`
|
|
roleSet services.RoleSet `json:"-"`
|
|
Database types.Database `json:"database"`
|
|
}
|
|
|
|
type databaseListings []databaseListing
|
|
|
|
func (l databaseListings) Len() int {
|
|
return len(l)
|
|
}
|
|
|
|
func (l databaseListings) Less(i, j int) bool {
|
|
if l[i].Proxy != l[j].Proxy {
|
|
return l[i].Proxy < l[j].Proxy
|
|
}
|
|
if l[i].Cluster != l[j].Cluster {
|
|
return l[i].Cluster < l[j].Cluster
|
|
}
|
|
return l[i].Database.GetName() < l[j].Database.GetName()
|
|
}
|
|
|
|
func (l databaseListings) Swap(i, j int) {
|
|
l[i], l[j] = l[j], l[i]
|
|
}
|
|
|
|
func listDatabasesAllClusters(cf *CLIConf) error {
|
|
tracer := cf.TracingProvider.Tracer(teleport.ComponentTSH)
|
|
clusters, err := getClusterClients(cf)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
defer func() {
|
|
// close all clients
|
|
for _, cluster := range clusters {
|
|
_ = cluster.Close()
|
|
}
|
|
}()
|
|
|
|
// Fetch database listings for all clusters in parallel with an upper limit
|
|
group, groupCtx := errgroup.WithContext(cf.Context)
|
|
group.SetLimit(10)
|
|
|
|
// mu guards access to dbListings
|
|
var (
|
|
mu sync.Mutex
|
|
dbListings databaseListings
|
|
errors []error
|
|
)
|
|
for _, cluster := range clusters {
|
|
cluster := cluster
|
|
if cluster.connectionError != nil {
|
|
mu.Lock()
|
|
errors = append(errors, cluster.connectionError)
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
|
|
group.Go(func() error {
|
|
ctx, span := tracer.Start(
|
|
groupCtx,
|
|
"ListDatabases",
|
|
oteltrace.WithAttributes(attribute.String("cluster", cluster.name)))
|
|
defer span.End()
|
|
|
|
logger := log.WithField("cluster", cluster.name)
|
|
databases, err := cluster.proxy.FindDatabasesByFiltersForCluster(ctx, cluster.req, cluster.name)
|
|
if err != nil {
|
|
logger.Errorf("Failed to get databases: %v.", err)
|
|
|
|
mu.Lock()
|
|
errors = append(errors, trace.ConnectionProblem(err, "failed to list databases for cluster %s: %v", cluster.name, err))
|
|
mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
roleSet, err := fetchRoleSetForCluster(ctx, cluster.profile, cluster.proxy, cluster.name)
|
|
if err != nil {
|
|
log.Debugf("Failed to fetch user roles: %v.", err)
|
|
}
|
|
|
|
localDBListings := make(databaseListings, 0, len(databases))
|
|
for _, database := range databases {
|
|
localDBListings = append(localDBListings, databaseListing{
|
|
Proxy: cluster.profile.ProxyURL.Host,
|
|
Cluster: cluster.name,
|
|
roleSet: roleSet,
|
|
Database: database,
|
|
})
|
|
}
|
|
mu.Lock()
|
|
dbListings = append(dbListings, localDBListings...)
|
|
mu.Unlock()
|
|
|
|
return nil
|
|
|
|
})
|
|
}
|
|
|
|
if err := group.Wait(); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if len(dbListings) == 0 && len(errors) > 0 {
|
|
return trace.NewAggregate(errors...)
|
|
}
|
|
|
|
sort.Sort(dbListings)
|
|
|
|
profile, err := cf.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
var active []tlsca.RouteToDatabase
|
|
if profile != nil {
|
|
active = profile.Databases
|
|
}
|
|
|
|
format := strings.ToLower(cf.Format)
|
|
switch format {
|
|
case teleport.Text, "":
|
|
printDatabasesWithClusters(cf.SiteName, dbListings, active, cf.Verbose)
|
|
case teleport.JSON, teleport.YAML:
|
|
out, err := serializeDatabasesAllClusters(dbListings, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(out)
|
|
default:
|
|
return trace.BadParameter("unsupported format %q", format)
|
|
}
|
|
|
|
return trace.NewAggregate(errors...)
|
|
}
|
|
|
|
// onDatabaseLogin implements "tsh db login" command.
|
|
func onDatabaseLogin(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
database, err := getDatabase(cf, tc, cf.DatabaseService)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
route := tlsca.RouteToDatabase{
|
|
ServiceName: cf.DatabaseService,
|
|
Protocol: database.GetProtocol(),
|
|
Username: cf.DatabaseUser,
|
|
Database: cf.DatabaseName,
|
|
}
|
|
|
|
if err := databaseLogin(cf, tc, route); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Print after-login message.
|
|
templateData := map[string]string{
|
|
"name": route.ServiceName,
|
|
}
|
|
|
|
// DynamoDB does not support a connect command, so don't try to print one.
|
|
if database.GetProtocol() != defaults.ProtocolDynamoDB {
|
|
templateData["connectCommand"] = utils.Color(utils.Yellow, formatDatabaseConnectCommand(cf.SiteName, route))
|
|
}
|
|
|
|
requires := getDBLocalProxyRequirement(tc, &route)
|
|
if requires.localProxy {
|
|
templateData["proxyCommand"] = utils.Color(utils.Yellow, formatDatabaseProxyCommand(cf.SiteName, route))
|
|
} else {
|
|
templateData["configCommand"] = utils.Color(utils.Yellow, formatDatabaseConfigCommand(cf.SiteName, route))
|
|
}
|
|
return trace.Wrap(dbConnectTemplate.Execute(cf.Stdout(), templateData))
|
|
}
|
|
|
|
// checkAndSetDBRouteDefaults checks the database route and sets defaults for certificate generation.
|
|
func checkAndSetDBRouteDefaults(r *tlsca.RouteToDatabase) error {
|
|
// When generating certificate for MongoDB access, database username must
|
|
// be encoded into it. This is required to be able to tell which database
|
|
// user to authenticate the connection as.
|
|
// Elasticsearch needs database username too.
|
|
if r.Username == "" {
|
|
switch r.Protocol {
|
|
case defaults.ProtocolMongoDB, defaults.ProtocolElasticsearch:
|
|
return trace.BadParameter("please provide the database user name using the --db-user flag")
|
|
case defaults.ProtocolRedis:
|
|
// Default to "default" in the same way as Redis does. We need the username to check access on our side.
|
|
// ref: https://redis.io/commands/auth
|
|
r.Username = defaults.DefaultRedisUsername
|
|
}
|
|
}
|
|
if r.Database != "" {
|
|
switch r.Protocol {
|
|
case defaults.ProtocolDynamoDB:
|
|
log.Warnf("Database %v protocol %v does not support --db-name flag, ignoring --db-name=%v",
|
|
r.ServiceName, defaults.ReadableDatabaseProtocol(r.Protocol), r.Database)
|
|
r.Database = ""
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func databaseLogin(cf *CLIConf, tc *client.TeleportClient, route tlsca.RouteToDatabase) error {
|
|
log.Debugf("Fetching database access certificate for %s on cluster %v.", route, tc.SiteName)
|
|
if err := checkAndSetDBRouteDefaults(&route); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
profile, err := tc.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Identity files themselves act as the database credentials (if any), so
|
|
// don't bother fetching new certs.
|
|
if profile.IsVirtual {
|
|
log.Info("Note: already logged in due to an identity file (`-i ...`); will only update database config files.")
|
|
} else {
|
|
var key *client.Key
|
|
if err = client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
key, err = tc.IssueUserCertsWithMFA(cf.Context, client.ReissueParams{
|
|
RouteToCluster: tc.SiteName,
|
|
RouteToDatabase: proto.RouteToDatabase{
|
|
ServiceName: route.ServiceName,
|
|
Protocol: route.Protocol,
|
|
Username: route.Username,
|
|
Database: route.Database,
|
|
},
|
|
AccessRequests: profile.ActiveRequests.AccessRequests,
|
|
}, nil /*applyOpts*/)
|
|
return trace.Wrap(err)
|
|
}); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err = tc.LocalAgent().AddDatabaseKey(key); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Refresh the profile.
|
|
profile, err = tc.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
// Update the database-specific connection profile file.
|
|
err = dbprofile.Add(cf.Context, tc, route, *profile)
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// onDatabaseLogout implements "tsh db logout" command.
|
|
func onDatabaseLogout(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
profile, err := tc.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
activeDatabases, err := profile.DatabasesForCluster(tc.SiteName)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if profile.IsVirtual {
|
|
log.Info("Note: an identity file is in use (`-i ...`); will only update database config files.")
|
|
}
|
|
|
|
var logout []tlsca.RouteToDatabase
|
|
// If database name wasn't given on the command line, log out of all.
|
|
if cf.DatabaseService == "" {
|
|
logout = activeDatabases
|
|
} else {
|
|
for _, db := range activeDatabases {
|
|
if db.ServiceName == cf.DatabaseService {
|
|
logout = append(logout, db)
|
|
}
|
|
}
|
|
if len(logout) == 0 {
|
|
return trace.BadParameter("Not logged into database %q",
|
|
tc.DatabaseService)
|
|
}
|
|
}
|
|
for _, db := range logout {
|
|
if err := databaseLogout(tc, db, profile.IsVirtual); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
if len(logout) == 1 {
|
|
fmt.Println("Logged out of database", logout[0].ServiceName)
|
|
} else {
|
|
fmt.Println("Logged out of all databases")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func databaseLogout(tc *client.TeleportClient, db tlsca.RouteToDatabase, virtual bool) error {
|
|
// First remove respective connection profile.
|
|
err := dbprofile.Delete(tc, db)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Then remove the certificate from the keystore, but only for real
|
|
// profiles.
|
|
if !virtual {
|
|
err = tc.LogoutDatabase(db.ServiceName)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// onDatabaseEnv implements "tsh db env" command.
|
|
func onDatabaseEnv(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
database, err := pickActiveDatabase(cf)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if !dbprofile.IsSupported(*database) {
|
|
return trace.BadParameter(formatDbCmdUnsupportedDBProtocol(cf, database))
|
|
}
|
|
requires := getDBLocalProxyRequirement(tc, database)
|
|
if requires.localProxy {
|
|
return trace.BadParameter(formatDbCmdUnsupported(cf, database, requires.localProxyReasons...))
|
|
}
|
|
|
|
env, err := dbprofile.Env(tc, *database)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
format := strings.ToLower(cf.Format)
|
|
switch format {
|
|
case dbFormatText, "":
|
|
for k, v := range env {
|
|
fmt.Printf("export %v=%v\n", k, v)
|
|
}
|
|
case dbFormatJSON, dbFormatYAML:
|
|
out, err := serializeDatabaseEnvironment(env, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(out)
|
|
default:
|
|
return trace.BadParameter("unsupported format %q", cf.Format)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func serializeDatabaseEnvironment(env map[string]string, format string) (string, error) {
|
|
var out []byte
|
|
var err error
|
|
if format == dbFormatJSON {
|
|
out, err = utils.FastMarshalIndent(env, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(env)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
// onDatabaseConfig implements "tsh db config" command.
|
|
func onDatabaseConfig(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
profile, err := tc.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
database, err := pickActiveDatabase(cf)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
requires := getDBLocalProxyRequirement(tc, database)
|
|
// "tsh db config" prints out instructions for native clients to connect to
|
|
// the remote proxy directly. Return errors here when direct connection
|
|
// does NOT work (e.g. when ALPN local proxy is required).
|
|
if requires.localProxy {
|
|
msg := formatDbCmdUnsupported(cf, database, requires.localProxyReasons...)
|
|
return trace.BadParameter(msg)
|
|
}
|
|
|
|
host, port := tc.DatabaseProxyHostPort(*database)
|
|
rootCluster, err := tc.RootClusterName(cf.Context)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
format := strings.ToLower(cf.Format)
|
|
switch format {
|
|
case dbFormatCommand:
|
|
cmd, err := dbcmd.NewCmdBuilder(tc, profile, database, rootCluster,
|
|
dbcmd.WithPrintFormat(),
|
|
dbcmd.WithLogger(log),
|
|
).GetConnectCommand()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(strings.Join(cmd.Env, " "), cmd.Path, strings.Join(cmd.Args[1:], " "))
|
|
case dbFormatJSON, dbFormatYAML:
|
|
configInfo := &dbConfigInfo{
|
|
database.ServiceName, host, port, database.Username,
|
|
database.Database, profile.CACertPathForCluster(rootCluster),
|
|
profile.DatabaseCertPathForCluster(tc.SiteName, database.ServiceName),
|
|
profile.KeyPath(),
|
|
}
|
|
out, err := serializeDatabaseConfig(configInfo, format)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
fmt.Println(out)
|
|
default:
|
|
fmt.Printf(`Name: %v
|
|
Host: %v
|
|
Port: %v
|
|
User: %v
|
|
Database: %v
|
|
CA: %v
|
|
Cert: %v
|
|
Key: %v
|
|
`,
|
|
database.ServiceName, host, port, database.Username,
|
|
database.Database, profile.CACertPathForCluster(rootCluster),
|
|
profile.DatabaseCertPathForCluster(tc.SiteName, database.ServiceName), profile.KeyPath())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type dbConfigInfo struct {
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
User string `json:"user,omitempty"`
|
|
Database string `json:"database,omitempty"`
|
|
CA string `json:"ca"`
|
|
Cert string `json:"cert"`
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
func serializeDatabaseConfig(configInfo *dbConfigInfo, format string) (string, error) {
|
|
var out []byte
|
|
var err error
|
|
if format == dbFormatJSON {
|
|
out, err = utils.FastMarshalIndent(configInfo, "", " ")
|
|
} else {
|
|
out, err = yaml.Marshal(configInfo)
|
|
}
|
|
return string(out), trace.Wrap(err)
|
|
}
|
|
|
|
// 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(ctx context.Context, cf *CLIConf,
|
|
tc *client.TeleportClient, profile *client.ProfileStatus,
|
|
route *tlsca.RouteToDatabase, db types.Database, rootClusterName string,
|
|
requires *dbLocalProxyRequirement,
|
|
) ([]dbcmd.ConnectCommandFunc, error) {
|
|
if !requires.localProxy {
|
|
return nil, nil
|
|
}
|
|
if requires.tunnel {
|
|
log.Debugf("Starting local proxy tunnel because: %v", strings.Join(requires.tunnelReasons, ", "))
|
|
} else {
|
|
log.Debugf("Starting local proxy because: %v", strings.Join(requires.localProxyReasons, ", "))
|
|
}
|
|
|
|
listener, err := net.Listen("tcp", "localhost:0")
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
opts, err := prepareLocalProxyOptions(&localProxyConfig{
|
|
cliConf: cf,
|
|
teleportClient: tc,
|
|
profile: profile,
|
|
routeToDatabase: route,
|
|
database: db,
|
|
listener: listener,
|
|
localProxyTunnel: requires.tunnel,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
lp, err := mkLocalProxy(cf.Context, opts)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
go func() {
|
|
defer listener.Close()
|
|
if err := lp.Start(ctx); err != nil {
|
|
log.WithError(err).Errorf("Failed to start local proxy")
|
|
}
|
|
}()
|
|
|
|
addr, err := utils.ParseAddr(lp.GetAddr())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// When connecting over TLS, psql only validates hostname against presented
|
|
// certificate's DNS names. As such, connecting to 127.0.0.1 will fail
|
|
// validation, so connect to localhost.
|
|
host := "localhost"
|
|
cmdOpts := []dbcmd.ConnectCommandFunc{
|
|
dbcmd.WithLocalProxy(host, addr.Port(0), profile.CACertPathForCluster(rootClusterName)),
|
|
}
|
|
if requires.tunnel {
|
|
cmdOpts = append(cmdOpts, dbcmd.WithNoTLS())
|
|
}
|
|
return cmdOpts, nil
|
|
}
|
|
|
|
// localProxyConfig is an argument pack used in prepareLocalProxyOptions().
|
|
type localProxyConfig struct {
|
|
cliConf *CLIConf
|
|
teleportClient *client.TeleportClient
|
|
profile *client.ProfileStatus
|
|
routeToDatabase *tlsca.RouteToDatabase
|
|
database types.Database
|
|
listener net.Listener
|
|
// localProxyTunnel keeps the same value as cliConf.LocalProxyTunnel, but
|
|
// it's always true for Snowflake database. Value is copied here to not modify
|
|
// cli arguments directly.
|
|
localProxyTunnel bool
|
|
}
|
|
|
|
// prepareLocalProxyOptions created localProxyOpts needed to create local proxy from localProxyConfig.
|
|
func prepareLocalProxyOptions(arg *localProxyConfig) (*localProxyOpts, error) {
|
|
certFile := arg.cliConf.LocalProxyCertFile
|
|
keyFile := arg.cliConf.LocalProxyKeyFile
|
|
if arg.localProxyTunnel && certFile == "" && keyFile == "" {
|
|
// --tunnel implies the local proxy is configured with db certs.
|
|
// If db certs are not specified but exist in the user profile, load
|
|
// them from the profile.
|
|
certFile = arg.profile.DatabaseCertPathForCluster(arg.teleportClient.SiteName, arg.routeToDatabase.ServiceName)
|
|
keyFile = arg.profile.KeyPath()
|
|
}
|
|
certs, err := mkLocalProxyCerts(certFile, keyFile)
|
|
if err != nil {
|
|
if !arg.localProxyTunnel {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
// local proxy with --tunnel monitors and reissue its certs,
|
|
// so it's ok if certs can't be loaded here.
|
|
certs = nil
|
|
}
|
|
|
|
alpnProtocol, err := common.ToALPNProtocol(arg.routeToDatabase.Protocol)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
opts := &localProxyOpts{
|
|
proxyAddr: arg.teleportClient.WebProxyAddr,
|
|
listener: arg.listener,
|
|
protocols: []common.Protocol{alpnProtocol},
|
|
insecure: arg.cliConf.InsecureSkipVerify,
|
|
certs: certs,
|
|
}
|
|
opts.configOpts = append(opts.configOpts, alpnproxy.WithALPNConnUpgradeTest(arg.cliConf.Context, arg.teleportClient.RootClusterCACertPool))
|
|
|
|
if arg.localProxyTunnel {
|
|
dbRoute := *arg.routeToDatabase
|
|
if err := checkAndSetDBRouteDefaults(&dbRoute); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
opts.middleware = client.NewDBCertChecker(arg.teleportClient, dbRoute, nil)
|
|
}
|
|
|
|
// To set correct MySQL server version DB proxy needs additional protocol.
|
|
if !arg.localProxyTunnel && arg.routeToDatabase.Protocol == defaults.ProtocolMySQL {
|
|
if arg.database == nil {
|
|
var err error
|
|
arg.database, err = getDatabase(arg.cliConf, arg.teleportClient, arg.routeToDatabase.ServiceName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
mysqlServerVersionProto := mySQLVersionToProto(arg.database)
|
|
if mysqlServerVersionProto != "" {
|
|
opts.protocols = append(opts.protocols, common.Protocol(mysqlServerVersionProto))
|
|
}
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
// mySQLVersionToProto returns base64 encoded MySQL server version with MySQL protocol prefix.
|
|
// If version is not set in the past database an empty string is returned.
|
|
func mySQLVersionToProto(database types.Database) string {
|
|
version := database.GetMySQLServerVersion()
|
|
if version == "" {
|
|
return ""
|
|
}
|
|
|
|
versionBase64 := base64.StdEncoding.EncodeToString([]byte(version))
|
|
|
|
// Include MySQL server version
|
|
return string(common.ProtocolMySQLWithVerPrefix) + versionBase64
|
|
}
|
|
|
|
// onDatabaseConnect implements "tsh db connect" command.
|
|
func onDatabaseConnect(cf *CLIConf) error {
|
|
tc, err := makeClient(cf, false)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
profile, err := tc.ProfileStatus()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
route, database, err := getDatabaseInfo(cf, tc, cf.DatabaseService)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if route.Protocol == defaults.ProtocolDynamoDB {
|
|
return trace.BadParameter(formatDbCmdUnsupportedDBProtocol(cf, route))
|
|
}
|
|
|
|
requires := getDBLocalProxyRequirement(tc, route, withConnectRequirements(cf.Context, tc, route))
|
|
if err := maybeDatabaseLogin(cf, tc, profile, route, requires); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
rootClusterName, err := tc.RootClusterName(cf.Context)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// To avoid termination of background DB teleport proxy when a SIGINT is received don't use the cf.Context.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
opts, err := maybeStartLocalProxy(ctx, cf, tc, profile, route, database, rootClusterName, requires)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
opts = append(opts, dbcmd.WithLogger(log))
|
|
|
|
if opts, err = maybeAddDBUserPassword(database, opts); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
bb := dbcmd.NewCmdBuilder(tc, profile, route, rootClusterName, opts...)
|
|
cmd, err := bb.GetConnectCommand()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
log.Debug(cmd.String())
|
|
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stdin = os.Stdin
|
|
|
|
// Use io.MultiWriter to duplicate stderr to the capture writer. The
|
|
// captured stderr can be used for diagnosing command failures. The capture
|
|
// writer captures up to a fixed number to limit memory usage.
|
|
peakStderr := utils.NewCaptureNBytesWriter(dbcmd.PeakStderrSize)
|
|
cmd.Stderr = io.MultiWriter(os.Stderr, peakStderr)
|
|
|
|
err = cmd.Run()
|
|
if err != nil {
|
|
return dbcmd.ConvertCommandError(cmd, err, string(peakStderr.Bytes()))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getDatabaseInfo fetches information about the database from tsh profile is DB is active in profile. Otherwise,
|
|
// the ListDatabases endpoint is called.
|
|
func getDatabaseInfo(cf *CLIConf, tc *client.TeleportClient, dbName string) (*tlsca.RouteToDatabase, types.Database, error) {
|
|
database, err := pickActiveDatabase(cf)
|
|
if err == nil {
|
|
switch database.Protocol {
|
|
case defaults.ProtocolCassandra:
|
|
// Cassandra CLI connection require database resource to determine
|
|
// if the target database is AWS hosted in order to skip the password prompt.
|
|
default:
|
|
return database, nil, nil
|
|
}
|
|
}
|
|
if err != nil && !trace.IsNotFound(err) {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
db, err := getDatabase(cf, tc, dbName)
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
username := cf.DatabaseUser
|
|
databaseName := cf.DatabaseName
|
|
if database != nil {
|
|
if username == "" {
|
|
username = database.Username
|
|
}
|
|
if databaseName == "" {
|
|
databaseName = database.Database
|
|
}
|
|
}
|
|
|
|
return &tlsca.RouteToDatabase{
|
|
ServiceName: db.GetName(),
|
|
Protocol: db.GetProtocol(),
|
|
Username: username,
|
|
Database: databaseName,
|
|
}, db, nil
|
|
}
|
|
|
|
func getDatabase(cf *CLIConf, tc *client.TeleportClient, dbName string) (types.Database, error) {
|
|
var databases []types.Database
|
|
err := client.RetryWithRelogin(cf.Context, tc, func() error {
|
|
var err error
|
|
databases, err = tc.ListDatabases(cf.Context, &proto.ListResourcesRequest{
|
|
Namespace: tc.Namespace,
|
|
PredicateExpression: fmt.Sprintf(`name == "%s"`, dbName),
|
|
})
|
|
return trace.Wrap(err)
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
if len(databases) == 0 {
|
|
return nil, trace.NotFound(
|
|
"database %q not found, use '%v' to see registered databases", dbName, formatDatabaseListCommand(cf.SiteName))
|
|
}
|
|
return databases[0], nil
|
|
}
|
|
|
|
func needDatabaseRelogin(cf *CLIConf, tc *client.TeleportClient, route *tlsca.RouteToDatabase, profile *client.ProfileStatus, requires *dbLocalProxyRequirement) (bool, error) {
|
|
if (requires.localProxy && requires.tunnel) || isLocalProxyTunnelRequested(cf) {
|
|
// We don't need to login if using a local proxy tunnel,
|
|
// because a local proxy tunnel will handle db login itself.
|
|
return false, nil
|
|
}
|
|
found := false
|
|
activeDatabases, err := profile.DatabasesForCluster(tc.SiteName)
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
|
|
for _, v := range activeDatabases {
|
|
if v.ServiceName == route.ServiceName {
|
|
found = true
|
|
}
|
|
}
|
|
// database not found in active list of databases.
|
|
if !found {
|
|
return true, nil
|
|
}
|
|
|
|
// For database protocols where database username is encoded in client certificate like Mongo
|
|
// check if the command line dbUser matches the encoded username in database certificate.
|
|
dbInfoChanged, err := dbInfoHasChanged(cf, profile.DatabaseCertPathForCluster(tc.SiteName, route.ServiceName))
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
if dbInfoChanged {
|
|
return true, nil
|
|
}
|
|
// Call API and check if a user needs to use MFA to connect to the database.
|
|
mfaRequired, err := isMFADatabaseAccessRequired(cf.Context, tc, route)
|
|
return mfaRequired, trace.Wrap(err)
|
|
}
|
|
|
|
// maybeDatabaseLogin checks if cert is still valid. If not valid, trigger db login logic.
|
|
// returns a true/false indicating whether database login was triggered.
|
|
func maybeDatabaseLogin(cf *CLIConf, tc *client.TeleportClient, profile *client.ProfileStatus, route *tlsca.RouteToDatabase, requires *dbLocalProxyRequirement) error {
|
|
reloginNeeded, err := needDatabaseRelogin(cf, tc, route, profile, requires)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if reloginNeeded {
|
|
return trace.Wrap(databaseLogin(cf, tc, *route))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dbInfoHasChanged checks if cliConf.DatabaseUser or cliConf.DatabaseName info has changed in the user database certificate.
|
|
func dbInfoHasChanged(cf *CLIConf, certPath string) (bool, error) {
|
|
if cf.DatabaseUser == "" && cf.DatabaseName == "" {
|
|
return false, nil
|
|
}
|
|
|
|
buff, err := os.ReadFile(certPath)
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
cert, err := tlsca.ParseCertificatePEM(buff)
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
identity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
|
|
if cf.DatabaseUser != "" && cf.DatabaseUser != identity.RouteToDatabase.Username {
|
|
log.Debugf("Will reissue database certificate for user %s (was %s)", cf.DatabaseUser, identity.RouteToDatabase.Username)
|
|
return true, nil
|
|
}
|
|
if cf.DatabaseName != "" && cf.DatabaseName != identity.RouteToDatabase.Database {
|
|
log.Debugf("Will reissue database certificate for database name %s (was %s)", cf.DatabaseName, identity.RouteToDatabase.Database)
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// isMFADatabaseAccessRequired calls the IsMFARequired endpoint in order to get from user roles if access to the database
|
|
// requires MFA.
|
|
func isMFADatabaseAccessRequired(ctx context.Context, tc *client.TeleportClient, database *tlsca.RouteToDatabase) (bool, error) {
|
|
proxy, err := tc.ConnectToProxy(ctx)
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
cluster, err := proxy.ConnectToCluster(ctx, tc.SiteName)
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
defer cluster.Close()
|
|
|
|
dbParam := proto.RouteToDatabase{
|
|
ServiceName: database.ServiceName,
|
|
Protocol: database.Protocol,
|
|
Username: database.Username,
|
|
Database: database.Database,
|
|
}
|
|
mfaResp, err := cluster.IsMFARequired(ctx, &proto.IsMFARequiredRequest{
|
|
Target: &proto.IsMFARequiredRequest_Database{
|
|
Database: &dbParam,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return false, trace.Wrap(err)
|
|
}
|
|
return mfaResp.GetRequired(), nil
|
|
}
|
|
|
|
// pickActiveDatabase returns the database the current profile is logged into.
|
|
//
|
|
// If logged into multiple databases, returns an error unless one specified
|
|
// explicitly via --db flag.
|
|
func pickActiveDatabase(cf *CLIConf) (*tlsca.RouteToDatabase, error) {
|
|
profile, err := cf.ProfileStatus()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
activeDatabases, err := profile.DatabasesForCluster(cf.SiteName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if len(activeDatabases) == 0 {
|
|
return nil, trace.NotFound("Please login using 'tsh db login' first")
|
|
}
|
|
|
|
name := cf.DatabaseService
|
|
if name == "" {
|
|
if len(activeDatabases) > 1 {
|
|
var services []string
|
|
for _, database := range activeDatabases {
|
|
services = append(services, database.ServiceName)
|
|
}
|
|
return nil, trace.BadParameter("Multiple databases are available (%v), please specify one using CLI argument",
|
|
strings.Join(services, ", "))
|
|
}
|
|
name = activeDatabases[0].ServiceName
|
|
}
|
|
for _, db := range activeDatabases {
|
|
if db.ServiceName == name {
|
|
// If database user or name were provided on the CLI,
|
|
// override the default ones.
|
|
if cf.DatabaseUser != "" {
|
|
db.Username = cf.DatabaseUser
|
|
}
|
|
if cf.DatabaseName != "" {
|
|
db.Database = cf.DatabaseName
|
|
}
|
|
return &db, nil
|
|
}
|
|
}
|
|
return nil, trace.NotFound("Not logged into database %q", name)
|
|
}
|
|
|
|
func formatDatabaseListCommand(clusterFlag string) string {
|
|
if clusterFlag == "" {
|
|
return "tsh db ls"
|
|
}
|
|
return fmt.Sprintf("tsh db ls --cluster=%v", clusterFlag)
|
|
}
|
|
|
|
// formatDatabaseConnectCommand formats an appropriate database connection
|
|
// command for a user based on the provided database parameters.
|
|
func formatDatabaseConnectCommand(clusterFlag string, active tlsca.RouteToDatabase) string {
|
|
cmdTokens := append(
|
|
[]string{"tsh", "db", "connect"},
|
|
formatDatabaseConnectArgs(clusterFlag, active)...,
|
|
)
|
|
return strings.Join(cmdTokens, " ")
|
|
}
|
|
|
|
// formatDatabaseConnectArgs generates the arguments for "tsh db connect" command.
|
|
func formatDatabaseConnectArgs(clusterFlag string, active tlsca.RouteToDatabase) (flags []string) {
|
|
// figure out if we need --db-user and --db-name
|
|
needUser := role.RequireDatabaseUserMatcher(active.Protocol)
|
|
needDatabase := role.RequireDatabaseNameMatcher(active.Protocol)
|
|
|
|
if clusterFlag != "" {
|
|
flags = append(flags, fmt.Sprintf("--cluster=%s", clusterFlag))
|
|
}
|
|
if active.Username == "" && needUser {
|
|
flags = append(flags, "--db-user=<user>")
|
|
}
|
|
if active.Database == "" && needDatabase {
|
|
flags = append(flags, "--db-name=<name>")
|
|
}
|
|
flags = append(flags, active.ServiceName)
|
|
return
|
|
}
|
|
|
|
// formatDatabaseProxyCommand formats the "tsh proxy db" command.
|
|
func formatDatabaseProxyCommand(clusterFlag string, active tlsca.RouteToDatabase) string {
|
|
cmdTokens := append(
|
|
// "--tunnel" mode is more user friendly and supports all DB protocols.
|
|
[]string{"tsh", "proxy", "db", "--tunnel"},
|
|
// Rest of the args are the same as "tsh db connect".
|
|
formatDatabaseConnectArgs(clusterFlag, active)...,
|
|
)
|
|
return strings.Join(cmdTokens, " ")
|
|
}
|
|
|
|
// formatDatabaseConfigCommand formats the "tsh db config" command.
|
|
func formatDatabaseConfigCommand(clusterFlag string, db tlsca.RouteToDatabase) string {
|
|
if clusterFlag == "" {
|
|
return fmt.Sprintf("tsh db config --format=cmd %v", db.ServiceName)
|
|
}
|
|
return fmt.Sprintf("tsh db config --cluster=%v --format=cmd %v", clusterFlag, db.ServiceName)
|
|
}
|
|
|
|
// dbLocalProxyRequirement describes local proxy requirements for connecting to a database.
|
|
type dbLocalProxyRequirement struct {
|
|
// localProxy is whether a local proxy is required to connect.
|
|
localProxy bool
|
|
// localProxyReasons is a list of reasons for why local proxy is required.
|
|
localProxyReasons []string
|
|
// tunnel is whether a local proxy tunnel is required to connect.
|
|
tunnel bool
|
|
// tunnelReasons is a list of reasons for why a tunnel is required.
|
|
tunnelReasons []string
|
|
}
|
|
|
|
// addLocalProxy sets the local proxy requirement and appends reasons.
|
|
func (r *dbLocalProxyRequirement) addLocalProxy(reasons ...string) {
|
|
r.localProxy = true
|
|
r.localProxyReasons = append(r.localProxyReasons, reasons...)
|
|
}
|
|
|
|
// addLocalProxyWithTunnel sets the local proxy and tunnel requirements,
|
|
// and appends reasons for both.
|
|
func (r *dbLocalProxyRequirement) addLocalProxyWithTunnel(reasons ...string) {
|
|
r.addLocalProxy(reasons...)
|
|
r.tunnel = true
|
|
r.tunnelReasons = append(r.tunnelReasons, reasons...)
|
|
}
|
|
|
|
// requireOpt is an optional requirement function used when getting requirements,
|
|
// that allows the caller to add further requirements.
|
|
type requireOpt func(r *dbLocalProxyRequirement)
|
|
|
|
// getDBLocalProxyRequirement determines what local proxy settings are required
|
|
// for a given database.
|
|
func getDBLocalProxyRequirement(tc *client.TeleportClient, route *tlsca.RouteToDatabase, opts ...requireOpt) *dbLocalProxyRequirement {
|
|
var out dbLocalProxyRequirement
|
|
switch tc.PrivateKeyPolicy {
|
|
case keys.PrivateKeyPolicyHardwareKey, keys.PrivateKeyPolicyHardwareKeyTouch:
|
|
out.addLocalProxyWithTunnel(formatKeyPolicyReason(tc.PrivateKeyPolicy))
|
|
}
|
|
|
|
switch route.Protocol {
|
|
case defaults.ProtocolSnowflake,
|
|
defaults.ProtocolDynamoDB,
|
|
defaults.ProtocolSQLServer,
|
|
defaults.ProtocolCassandra:
|
|
// Some protocols only work in the local tunnel mode.
|
|
out.addLocalProxyWithTunnel(formatDBProtocolReason(route.Protocol))
|
|
case defaults.ProtocolMySQL:
|
|
if tc.TLSRoutingEnabled {
|
|
out.addLocalProxy(fmt.Sprintf("%v and %v",
|
|
formatDBProtocolReason(route.Protocol),
|
|
formatTLSRoutingReason(tc.SiteName)))
|
|
}
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(&out)
|
|
}
|
|
return &out
|
|
}
|
|
|
|
// withConnectRequirements is requirement option fn that adds requirements specific to "tsh db connect".
|
|
func withConnectRequirements(ctx context.Context, tc *client.TeleportClient, route *tlsca.RouteToDatabase) requireOpt {
|
|
return func(r *dbLocalProxyRequirement) {
|
|
if !r.localProxy && tc.TLSRoutingEnabled {
|
|
r.addLocalProxy(formatTLSRoutingReason(tc.SiteName))
|
|
}
|
|
switch route.Protocol {
|
|
case defaults.ProtocolElasticsearch:
|
|
// ElasticSearch access can work without a local proxy tunnel, but not
|
|
// via `tsh db connect`.
|
|
// (elasticsearch-sql-cli cannot be configured to use specific certs).
|
|
r.addLocalProxyWithTunnel(formatDBProtocolReason(route.Protocol))
|
|
}
|
|
if r.localProxy && r.tunnel {
|
|
// don't check if MFA is required, because a local proxy tunnel is
|
|
// already required. this avoids an extra API call.
|
|
return
|
|
}
|
|
// Call API and check if a user needs to use MFA to connect to the database.
|
|
mfaRequired, err := isMFADatabaseAccessRequired(ctx, tc, route)
|
|
if err != nil {
|
|
log.WithError(err).Debugf("error getting MFA requirement for database %v",
|
|
route.ServiceName)
|
|
} else if mfaRequired {
|
|
// When MFA is required, we should require a local proxy tunnel,
|
|
// because the local proxy tunnel can hold database MFA certs in-memory
|
|
// without a restricted 1-minute TTL. This is better for user experience.
|
|
r.addLocalProxyWithTunnel("MFA is required to connect to the database")
|
|
}
|
|
}
|
|
}
|
|
|
|
// formatKeyPolicyReason is a helper func that formats a private key policy "reason".
|
|
// The "reason" is used to explain why something happened.
|
|
func formatKeyPolicyReason(policy keys.PrivateKeyPolicy) string {
|
|
return fmt.Sprintf("private key policy is %v", policy)
|
|
}
|
|
|
|
// formatDBProtocolReason is a helper func that formats a database protocol
|
|
// "reason".
|
|
// The "reason" is used to explain why something happened.
|
|
func formatDBProtocolReason(protocol string) string {
|
|
return fmt.Sprintf("database protocol is %v",
|
|
defaults.ReadableDatabaseProtocol(protocol))
|
|
}
|
|
|
|
// formatTLSRoutingReason is a helper func that formats a cluster proxy
|
|
// TLS routing enabled "reason".
|
|
// The "reason" is used to explain why something happened.
|
|
func formatTLSRoutingReason(siteName string) string {
|
|
return fmt.Sprintf("cluster %v proxy is using TLS routing",
|
|
siteName)
|
|
}
|
|
|
|
// formatDbCmdUnsupported is a helper func that formats a generic unsupported DB error message.
|
|
// The "reasons" arguments, if given, should specify condition for which this DB subcommand
|
|
// is not supported, e.g. "TLS routing is enabled" or "using a local proxy without the --tunnel flag".
|
|
func formatDbCmdUnsupported(cf *CLIConf, route *tlsca.RouteToDatabase, reasons ...string) string {
|
|
templateData := map[string]any{
|
|
"command": cf.CommandWithBinary(),
|
|
"alternatives": getDbCmdAlternatives(cf.SiteName, route),
|
|
"reasons": reasons,
|
|
}
|
|
|
|
buf := bytes.NewBuffer(nil)
|
|
_ = dbCmdUnsupportedTemplate.Execute(buf, templateData)
|
|
return buf.String()
|
|
}
|
|
|
|
// formatDbCmdUnsupportedDBProtocol is a helper func that formats an unsupported DB protocol error message.
|
|
func formatDbCmdUnsupportedDBProtocol(cf *CLIConf, route *tlsca.RouteToDatabase) string {
|
|
reason := formatDBProtocolReason(route.Protocol)
|
|
return formatDbCmdUnsupported(cf, route, reason)
|
|
}
|
|
|
|
// getDbCmdAlternatives is a helper func that returns alternative tsh commands for connecting to a database.
|
|
func getDbCmdAlternatives(clusterFlag string, route *tlsca.RouteToDatabase) []string {
|
|
var alts []string
|
|
switch route.Protocol {
|
|
case defaults.ProtocolDynamoDB:
|
|
// DynamoDB only works with a local proxy tunnel and there is no "shell-like" cli, so `tsh db connect` doesn't make sense.
|
|
default:
|
|
// prefer displaying the connect command as the first suggested command alternative.
|
|
alts = append(alts, formatDatabaseConnectCommand(clusterFlag, *route))
|
|
}
|
|
// all db protocols support this command.
|
|
alts = append(alts, formatDatabaseProxyCommand(clusterFlag, *route))
|
|
return alts
|
|
}
|
|
|
|
const (
|
|
// dbFormatText prints database configuration in text format.
|
|
dbFormatText = "text"
|
|
// dbFormatCommand prints database connection command.
|
|
dbFormatCommand = "cmd"
|
|
// dbFormatJSON prints database info as JSON.
|
|
dbFormatJSON = "json"
|
|
// dbFormatYAML prints database info as YAML.
|
|
dbFormatYAML = "yaml"
|
|
)
|
|
|
|
var (
|
|
// dbCmdUnsupportedTemplate is the error message printed when some
|
|
// database subcommands are not supported.
|
|
dbCmdUnsupportedTemplate = template.Must(template.New("").Parse(`"{{.command}}" is not supported{{if .reasons}} when:
|
|
{{- range $reason := .reasons }}
|
|
- {{ $reason }}.
|
|
{{- end}}
|
|
{{- else}}.
|
|
{{- end}}
|
|
{{if eq (len .alternatives) 1}}
|
|
Please use the following command to connect to the database:
|
|
{{index .alternatives 0 -}}{{else}}
|
|
Please use one of the following commands to connect to the database:
|
|
{{- range .alternatives}}
|
|
{{.}}{{end -}}
|
|
{{- end}}`))
|
|
)
|
|
|
|
var (
|
|
// dbConnectTemplate is the message printed after a successful "tsh db login" on how to connect.
|
|
dbConnectTemplate = template.Must(template.New("").Parse(`Connection information for database "{{ .name }}" has been saved.
|
|
|
|
{{if .connectCommand -}}
|
|
|
|
You can now connect to it using the following command:
|
|
|
|
{{.connectCommand}}
|
|
|
|
{{end -}}
|
|
{{if .configCommand -}}
|
|
|
|
You can view the connect command for the native database CLI client:
|
|
|
|
{{ .configCommand }}
|
|
|
|
{{end -}}
|
|
{{if .proxyCommand -}}
|
|
|
|
You can start a local proxy for database GUI clients:
|
|
|
|
{{ .proxyCommand }}
|
|
|
|
{{end -}}
|
|
`))
|
|
)
|