mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-31 20:39:37 +08:00
* Add MFAAuthenticateResponse field to GenerateUserCerts * Pass MFA response for connection testers * Add checking MFA web endpoint * Address CRs
430 lines
14 KiB
Go
430 lines
14 KiB
Go
/*
|
|
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 conntest
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
|
|
apiclient "github.com/gravitational/teleport/api/client"
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/lib/client"
|
|
"github.com/gravitational/teleport/lib/client/conntest/database"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
alpn "github.com/gravitational/teleport/lib/srv/alpnproxy/common"
|
|
"github.com/gravitational/teleport/lib/srv/db/common/role"
|
|
)
|
|
|
|
// databasePinger describes the required methods to test a Database Connection.
|
|
type databasePinger interface {
|
|
// Ping tests the connection to the Database with a simple request.
|
|
Ping(ctx context.Context, params database.PingParams) error
|
|
|
|
// IsConnectionRefusedError returns whether the error is referring to a connection refused.
|
|
IsConnectionRefusedError(error) bool
|
|
|
|
// IsInvalidDatabaseUserError returns whether the error is referring to an invalid (non-existent) user.
|
|
IsInvalidDatabaseUserError(error) bool
|
|
|
|
// IsInvalidDatabaseNameError returns whether the error is referring to an invalid (non-existent) database name.
|
|
IsInvalidDatabaseNameError(error) bool
|
|
}
|
|
|
|
// ClientDatabaseConnectionTester contains the required auth.ClientI methods to test a Database Connection
|
|
type ClientDatabaseConnectionTester interface {
|
|
client.ALPNAuthClient
|
|
|
|
services.ConnectionsDiagnostic
|
|
apiclient.ListResourcesClient
|
|
}
|
|
|
|
// DatabaseConnectionTesterConfig defines the config fields for DatabaseConnectionTester.
|
|
type DatabaseConnectionTesterConfig struct {
|
|
// UserClient is an auth client that has a User's identity.
|
|
UserClient ClientDatabaseConnectionTester
|
|
|
|
// PublicProxyAddr is public address of the proxy
|
|
PublicProxyAddr string
|
|
|
|
// TLSRoutingEnabled indicates that proxy supports ALPN SNI server where
|
|
// all proxy services are exposed on a single TLS listener (Proxy Web Listener).
|
|
TLSRoutingEnabled bool
|
|
}
|
|
|
|
// DatabaseConnectionTester implements the ConnectionTester interface for Testing Database access.
|
|
type DatabaseConnectionTester struct {
|
|
cfg DatabaseConnectionTesterConfig
|
|
}
|
|
|
|
// NewDatabaseConnectionTester returns a new DatabaseConnectionTester
|
|
func NewDatabaseConnectionTester(cfg DatabaseConnectionTesterConfig) (*DatabaseConnectionTester, error) {
|
|
return &DatabaseConnectionTester{
|
|
cfg: cfg,
|
|
}, nil
|
|
}
|
|
|
|
// TestConnection tests the access to a database using:
|
|
// - auth Client using the User access
|
|
// - the resource name
|
|
// - database user and database name to connect to
|
|
//
|
|
// A new ConnectionDiagnostic is created and used to store the traces as it goes through the checkpoints
|
|
// To connect to the Database, we will create a cert-key pair and setup a Database client back to Teleport Proxy.
|
|
// The following checkpoints are reported:
|
|
// - database server for the requested database exists / the user's roles can access it
|
|
// - the user can use the requested database user and database name (per their roles)
|
|
// - the database is acessible and accepting connections from the database server
|
|
// - the database has the database user and database name that was requested
|
|
func (s *DatabaseConnectionTester) TestConnection(ctx context.Context, req TestConnectionRequest) (types.ConnectionDiagnostic, error) {
|
|
if req.ResourceKind != types.KindDatabase {
|
|
return nil, trace.BadParameter("invalid value for ResourceKind, expected %q got %q", types.KindDatabase, req.ResourceKind)
|
|
}
|
|
|
|
connectionDiagnosticID := uuid.NewString()
|
|
connectionDiagnostic, err := types.NewConnectionDiagnosticV1(
|
|
connectionDiagnosticID,
|
|
map[string]string{},
|
|
types.ConnectionDiagnosticSpecV1{
|
|
// We start with a failed state so that we don't need to set it to each return statement once an error is returned.
|
|
// if the test reaches the end, we force the test to be a success by calling
|
|
// connectionDiagnostic.SetMessage(types.DiagnosticMessageSuccess)
|
|
// connectionDiagnostic.SetSuccess(true)
|
|
Message: types.DiagnosticMessageFailed,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if err := s.cfg.UserClient.CreateConnectionDiagnostic(ctx, connectionDiagnostic); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
databaseServers, err := s.getDatabaseServers(ctx, req.ResourceName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if len(databaseServers) == 0 {
|
|
connDiag, err := s.appendDiagnosticTrace(ctx,
|
|
connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_RBAC_DATABASE,
|
|
"Database not found. "+
|
|
"Ensure your role grants access by adding it to the 'db_labels' property. "+
|
|
"This can also happen when you don't have a Database Agent proxying the database - "+
|
|
"you can fix that by adding the database labels to the 'db_service.resources.labels' in 'teleport.yaml' file of the database agent.",
|
|
trace.NotFound("%s not found", req.ResourceName),
|
|
)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return connDiag, nil
|
|
}
|
|
|
|
databaseServer := databaseServers[0]
|
|
routeToDatabase := proto.RouteToDatabase{
|
|
ServiceName: databaseServer.GetName(),
|
|
Protocol: databaseServer.GetDatabase().GetProtocol(),
|
|
Username: req.DatabaseUser,
|
|
Database: req.DatabaseName,
|
|
}
|
|
|
|
databasePinger, err := getDatabaseConnTester(routeToDatabase.Protocol)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if err := checkDatabaseLogin(routeToDatabase.Protocol, req.DatabaseUser, req.DatabaseName); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if _, err := s.appendDiagnosticTrace(ctx,
|
|
connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_RBAC_DATABASE,
|
|
"A Database Agent is available to proxy the connection to the Database.",
|
|
nil,
|
|
); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
listener, err := s.runALPNTunnel(ctx, req, routeToDatabase, connectionDiagnosticID)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
defer listener.Close()
|
|
|
|
ping, err := newPing(listener.Addr().String(), req.DatabaseUser, req.DatabaseName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if pingErr := databasePinger.Ping(ctx, ping); pingErr != nil {
|
|
connDiag, err := s.handlePingError(ctx, connectionDiagnosticID, pingErr, databasePinger)
|
|
return connDiag, trace.Wrap(err)
|
|
}
|
|
|
|
return s.handlePingSuccess(ctx, connectionDiagnosticID)
|
|
}
|
|
|
|
func (s *DatabaseConnectionTester) runALPNTunnel(ctx context.Context, req TestConnectionRequest, routeToDatabase proto.RouteToDatabase, connectionDiagnosticID string) (net.Listener, error) {
|
|
list, err := net.Listen("tcp", "localhost:0")
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
alpnProtocol, err := alpn.ToALPNProtocol(routeToDatabase.Protocol)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
mfaResponse, err := req.MFAResponse.GetOptionalMFAResponseProtoReq()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
err = client.RunALPNAuthTunnel(ctx, client.ALPNAuthTunnelConfig{
|
|
AuthClient: s.cfg.UserClient,
|
|
Listener: list,
|
|
Protocol: alpnProtocol,
|
|
Expires: time.Now().Add(time.Minute).UTC(),
|
|
PublicProxyAddr: s.cfg.PublicProxyAddr,
|
|
ConnectionDiagnosticID: connectionDiagnosticID,
|
|
RouteToDatabase: routeToDatabase,
|
|
InsecureSkipVerify: req.InsecureSkipVerify,
|
|
MFAResponse: mfaResponse,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return list, nil
|
|
}
|
|
|
|
func (s *DatabaseConnectionTester) getDatabaseServers(ctx context.Context, databaseName string) ([]types.DatabaseServer, error) {
|
|
// Lookup the Database Server that's proxying the requested Database.
|
|
listResourcesResponse, err := s.cfg.UserClient.ListResources(ctx, proto.ListResourcesRequest{
|
|
PredicateExpression: fmt.Sprintf(`name == "%s"`, databaseName),
|
|
ResourceType: types.KindDatabaseServer,
|
|
Limit: defaults.MaxIterationLimit,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
databaseServers, err := types.ResourcesWithLabels(listResourcesResponse.Resources).AsDatabaseServers()
|
|
return databaseServers, trace.Wrap(err)
|
|
}
|
|
|
|
func checkDatabaseLogin(protocol, databaseUser, databaseName string) error {
|
|
needUser := role.RequireDatabaseUserMatcher(protocol)
|
|
needDatabase := role.RequireDatabaseNameMatcher(protocol)
|
|
|
|
if needUser && databaseUser == "" {
|
|
return trace.BadParameter("missing required parameter Database User")
|
|
}
|
|
|
|
if needDatabase && databaseName == "" {
|
|
return trace.BadParameter("missing required parameter Database Name")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func newPing(alpnProxyAddr, databaseUser, databaseName string) (database.PingParams, error) {
|
|
proxyHost, proxyPortStr, err := net.SplitHostPort(alpnProxyAddr)
|
|
if err != nil {
|
|
return database.PingParams{}, trace.Wrap(err)
|
|
}
|
|
|
|
proxyPort, err := strconv.Atoi(proxyPortStr)
|
|
if err != nil {
|
|
return database.PingParams{}, trace.Wrap(err)
|
|
}
|
|
|
|
return database.PingParams{
|
|
Host: proxyHost,
|
|
Port: proxyPort,
|
|
Username: databaseUser,
|
|
DatabaseName: databaseName,
|
|
}, nil
|
|
}
|
|
|
|
func (s DatabaseConnectionTester) handlePingSuccess(ctx context.Context, connectionDiagnosticID string) (types.ConnectionDiagnostic, error) {
|
|
if _, err := s.appendDiagnosticTrace(ctx, connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
"Database is accessible from the Database Agent.",
|
|
nil,
|
|
); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if _, err := s.appendDiagnosticTrace(ctx, connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_RBAC_DATABASE_LOGIN,
|
|
"Access to Database User and Database Name granted.",
|
|
nil,
|
|
); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if _, err := s.appendDiagnosticTrace(ctx, connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_DATABASE_DB_USER,
|
|
"Database User exists in the Database.",
|
|
nil,
|
|
); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
connDiag, err := s.appendDiagnosticTrace(ctx, connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_DATABASE_DB_NAME,
|
|
"Database Name exists in the Database.",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
connDiag.SetMessage(types.DiagnosticMessageSuccess)
|
|
connDiag.SetSuccess(true)
|
|
|
|
if err := s.cfg.UserClient.UpdateConnectionDiagnostic(ctx, connDiag); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return connDiag, nil
|
|
}
|
|
|
|
func errorFromDatabaseService(pingErr error) bool {
|
|
// If the requested DB User/Name can't be used per RBAC checks, the Database Agent returns an error which gets here.
|
|
if strings.Contains(pingErr.Error(), "access to db denied. User does not have permissions. Confirm database user and name.") {
|
|
return true
|
|
}
|
|
|
|
// When there's an error when trying to use RDS IAM auth.
|
|
if strings.Contains(pingErr.Error(), "FATAL: PAM authentication failed for user") &&
|
|
strings.Contains(pingErr.Error(), "IAM policy") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (s DatabaseConnectionTester) handlePingError(ctx context.Context, connectionDiagnosticID string, pingErr error, databasePinger databasePinger) (types.ConnectionDiagnostic, error) {
|
|
// The Database Agent (lib/srv/db/server.go) might add an trace in some cases.
|
|
// Here, it must be ignored to prevent multiple failed traces.
|
|
if errorFromDatabaseService(pingErr) {
|
|
connDiag, err := s.cfg.UserClient.GetConnectionDiagnostic(ctx, connectionDiagnosticID)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return connDiag, nil
|
|
}
|
|
|
|
if databasePinger.IsConnectionRefusedError(pingErr) {
|
|
connDiag, err := s.appendDiagnosticTrace(ctx,
|
|
connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
"There was a connection problem between the Database Agent and the Database. "+
|
|
"Ensure the Database is running and accessible from the Database Agent.",
|
|
pingErr,
|
|
)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return connDiag, nil
|
|
}
|
|
|
|
// Requested DB User is allowed per RBAC rules, but those entities don't exist in the Database itself.
|
|
if databasePinger.IsInvalidDatabaseUserError(pingErr) {
|
|
connDiag, err := s.appendDiagnosticTrace(ctx,
|
|
connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_DATABASE_DB_USER,
|
|
"The Database rejected the provided Database User. Ensure that the database user exists.",
|
|
pingErr,
|
|
)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return connDiag, nil
|
|
}
|
|
|
|
// Requested DB Name is allowed per RBAC rules, but those entities don't exist in the Database itself.
|
|
if databasePinger.IsInvalidDatabaseNameError(pingErr) {
|
|
connDiag, err := s.appendDiagnosticTrace(ctx,
|
|
connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_DATABASE_DB_NAME,
|
|
"The Database rejected the provided Database Name. Ensure that the database name exists.",
|
|
pingErr,
|
|
)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return connDiag, nil
|
|
}
|
|
|
|
connDiag, err := s.appendDiagnosticTrace(ctx,
|
|
connectionDiagnosticID,
|
|
types.ConnectionDiagnosticTrace_UNKNOWN_ERROR,
|
|
fmt.Sprintf("Unknown error. %v", pingErr),
|
|
pingErr,
|
|
)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return connDiag, nil
|
|
}
|
|
|
|
func (s DatabaseConnectionTester) appendDiagnosticTrace(ctx context.Context, connectionDiagnosticID string, traceType types.ConnectionDiagnosticTrace_TraceType, message string, err error) (types.ConnectionDiagnostic, error) {
|
|
connDiag, err := s.cfg.UserClient.AppendDiagnosticTrace(
|
|
ctx,
|
|
connectionDiagnosticID,
|
|
types.NewTraceDiagnosticConnection(
|
|
traceType,
|
|
message,
|
|
err,
|
|
))
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return connDiag, nil
|
|
}
|
|
|
|
func getDatabaseConnTester(protocol string) (databasePinger, error) {
|
|
switch protocol {
|
|
case defaults.ProtocolPostgres:
|
|
return &database.PostgresPinger{}, nil
|
|
case defaults.ProtocolMySQL:
|
|
return &database.MySQLPinger{}, nil
|
|
}
|
|
return nil, trace.NotImplemented("database protocol %q is not supported yet for testing connection", protocol)
|
|
}
|