Files
teleport/lib/srv/db/mysql/engine.go
T
STeve (Xin) Huang 704a514742 AWS IAM role matching for database users (#20610)
* Add database user matching logic for databases require AWS IAM role

* Fix UT

* fix lint/typo

* fix typo in error message

* reduce bool return
2023-02-03 18:33:56 +00:00

460 lines
16 KiB
Go

/*
Copyright 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 mysql
import (
"context"
"crypto/tls"
"fmt"
"net"
"time"
"github.com/go-mysql-org/go-mysql/client"
"github.com/go-mysql-org/go-mysql/mysql"
"github.com/go-mysql-org/go-mysql/packet"
"github.com/go-mysql-org/go-mysql/server"
"github.com/gravitational/trace"
"github.com/sirupsen/logrus"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/utils/retryutils"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/srv/db/cloud"
"github.com/gravitational/teleport/lib/srv/db/common"
"github.com/gravitational/teleport/lib/srv/db/common/role"
"github.com/gravitational/teleport/lib/srv/db/mysql/protocol"
"github.com/gravitational/teleport/lib/utils"
)
// NewEngine create new MySQL engine.
func NewEngine(ec common.EngineConfig) common.Engine {
return &Engine{
EngineConfig: ec,
}
}
// Engine implements the MySQL database service that accepts client
// connections coming over reverse tunnel from the proxy and proxies
// them between the proxy and the MySQL database instance.
//
// Implements common.Engine.
type Engine struct {
// EngineConfig is the common database engine configuration.
common.EngineConfig
// proxyConn is a client connection.
proxyConn server.Conn
}
// InitializeConnection initializes the engine with client connection.
func (e *Engine) InitializeConnection(clientConn net.Conn, _ *common.Session) error {
// Make server conn to get access to protocol's WriteOK/WriteError methods.
e.proxyConn = server.Conn{Conn: packet.NewConn(clientConn)}
return nil
}
// SendError sends an error to connected client in the MySQL understandable format.
func (e *Engine) SendError(err error) {
if writeErr := e.proxyConn.WriteError(trace.Unwrap(err)); writeErr != nil {
e.Log.WithError(writeErr).Debugf("Failed to send error %q to MySQL client.", err)
}
}
// HandleConnection processes the connection from MySQL proxy coming
// over reverse tunnel.
//
// It handles all necessary startup actions, authorization and acts as a
// middleman between the proxy and the database intercepting and interpreting
// all messages i.e. doing protocol parsing.
func (e *Engine) HandleConnection(ctx context.Context, sessionCtx *common.Session) error {
// Perform authorization checks.
err := e.checkAccess(ctx, sessionCtx)
if err != nil {
return trace.Wrap(err)
}
// Establish connection to the MySQL server.
serverConn, err := e.connect(ctx, sessionCtx)
if err != nil {
if trace.IsLimitExceeded(err) {
return trace.LimitExceeded("could not connect to the database, please try again later")
}
return trace.Wrap(err)
}
defer func() {
err := serverConn.Close()
if err != nil {
e.Log.WithError(err).Error("Failed to close connection to MySQL server.")
}
}()
// Internally, updateServerVersion() updates databases only when database version
// is not set, or it has changed since previous call.
if err := e.updateServerVersion(sessionCtx, serverConn); err != nil {
// Log but do not fail connection if the version update fails.
e.Log.WithError(err).Warnf("Failed to update the MySQL server version.")
}
// Send back OK packet to indicate auth/connect success. At this point
// the original client should consider the connection phase completed.
err = e.proxyConn.WriteOK(nil)
if err != nil {
return trace.Wrap(err)
}
e.Audit.OnSessionStart(e.Context, sessionCtx, nil)
defer e.Audit.OnSessionEnd(e.Context, sessionCtx)
// Copy between the connections.
clientErrCh := make(chan error, 1)
serverErrCh := make(chan error, 1)
go e.receiveFromClient(e.proxyConn.Conn, serverConn, clientErrCh, sessionCtx)
go e.receiveFromServer(serverConn, e.proxyConn.Conn, serverErrCh)
select {
case err := <-clientErrCh:
e.Log.WithError(err).Debug("Client done.")
case err := <-serverErrCh:
e.Log.WithError(err).Debug("Server done.")
case <-ctx.Done():
e.Log.Debug("Context canceled.")
}
return nil
}
// updateServerVersion updates the server runtime version if the version reported by the database is different from
// the version in status configuration.
func (e *Engine) updateServerVersion(sessionCtx *common.Session, serverConn *client.Conn) error {
serverVersion := serverConn.GetServerVersion()
statusVersion := sessionCtx.Database.GetMySQLServerVersion()
// Update only when needed
if serverVersion != "" && serverVersion != statusVersion {
sessionCtx.Database.SetMySQLServerVersion(serverVersion)
}
return nil
}
// checkAccess does authorization check for MySQL connection about to be established.
func (e *Engine) checkAccess(ctx context.Context, sessionCtx *common.Session) error {
authPref, err := e.Auth.GetAuthPreference(ctx)
if err != nil {
return trace.Wrap(err)
}
state := sessionCtx.GetAccessState(authPref)
dbRoleMatchers := role.DatabaseRoleMatchers(
sessionCtx.Database,
sessionCtx.DatabaseUser,
sessionCtx.DatabaseName,
)
err = sessionCtx.Checker.CheckAccess(
sessionCtx.Database,
state,
dbRoleMatchers...,
)
if err != nil {
e.Audit.OnSessionStart(e.Context, sessionCtx, err)
return trace.Wrap(err)
}
return nil
}
// connect establishes connection to MySQL database.
func (e *Engine) connect(ctx context.Context, sessionCtx *common.Session) (*client.Conn, error) {
tlsConfig, err := e.Auth.GetTLSConfig(ctx, sessionCtx)
if err != nil {
return nil, trace.Wrap(err)
}
user := sessionCtx.DatabaseUser
connectOpt := func(conn *client.Conn) {
conn.SetTLSConfig(tlsConfig)
}
var dialer client.Dialer
var password string
switch {
case sessionCtx.Database.IsRDS(), sessionCtx.Database.IsRDSProxy():
password, err = e.Auth.GetRDSAuthToken(sessionCtx)
if err != nil {
return nil, trace.Wrap(err)
}
case sessionCtx.Database.IsCloudSQL():
// For Cloud SQL MySQL there is no IAM auth, so we use one-time passwords
// by resetting the database user password for each connection. Thus,
// acquire a lock to make sure all connection attempts to the same
// database and user are serialized.
retryCtx, cancel := context.WithTimeout(ctx, defaults.DatabaseConnectTimeout)
defer cancel()
lease, err := services.AcquireSemaphoreWithRetry(retryCtx, e.makeAcquireSemaphoreConfig(sessionCtx))
if err != nil {
return nil, trace.Wrap(err)
}
// Only release the semaphore after the connection has been established
// below. If the semaphore fails to release for some reason, it will
// expire in a minute on its own.
defer func() {
err := e.AuthClient.CancelSemaphoreLease(ctx, *lease)
if err != nil {
e.Log.WithError(err).Errorf("Failed to cancel lease: %v.", lease)
}
}()
password, err = e.Auth.GetCloudSQLPassword(ctx, sessionCtx)
if err != nil {
return nil, trace.Wrap(err)
}
// Get the client once for subsequent calls (it acquires a read lock).
gcpClient, err := e.CloudClients.GetGCPSQLAdminClient(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
// Detect whether the instance is set to require SSL.
// Fallback to not requiring SSL for access denied errors.
requireSSL, err := cloud.GetGCPRequireSSL(ctx, sessionCtx, gcpClient)
if err != nil && !trace.IsAccessDenied(err) {
return nil, trace.Wrap(err)
}
// Create ephemeral certificate and append to TLS config when
// the instance requires SSL. Also use a TLS dialer instead of
// the default net dialer when GCP requires SSL.
if requireSSL {
err = cloud.AppendGCPClientCert(ctx, sessionCtx, gcpClient, tlsConfig)
if err != nil {
return nil, trace.Wrap(err)
}
connectOpt = func(*client.Conn) {}
dialer = newGCPTLSDialer(tlsConfig)
}
case sessionCtx.Database.IsAzure():
password, err = e.Auth.GetAzureAccessToken(ctx, sessionCtx)
if err != nil {
return nil, trace.Wrap(err)
}
user = services.MakeAzureDatabaseLoginUsername(sessionCtx.Database, user)
}
// Use default net dialer unless it is already initialized.
if dialer == nil {
var nd net.Dialer
dialer = nd.DialContext
}
// TODO(r0mant): Set CLIENT_INTERACTIVE flag on the client?
conn, err := client.ConnectWithDialer(ctx, "tcp", sessionCtx.Database.GetURI(),
user,
password,
sessionCtx.DatabaseName,
dialer,
connectOpt,
// client-set capabilities only.
// TODO(smallinsky) Forward "real" capabilities from mysql client to mysql server.
withClientCapabilities(
mysql.CLIENT_MULTI_RESULTS,
mysql.CLIENT_MULTI_STATEMENTS,
),
)
if err != nil {
return nil, common.ConvertConnectError(err, sessionCtx)
}
return conn, nil
}
func withClientCapabilities(caps ...uint32) func(conn *client.Conn) {
return func(conn *client.Conn) {
for _, cap := range caps {
conn.SetCapability(cap)
}
}
}
// receiveFromClient relays protocol messages received from MySQL client
// to MySQL database.
func (e *Engine) receiveFromClient(clientConn, serverConn net.Conn, clientErrCh chan<- error, sessionCtx *common.Session) {
log := e.Log.WithFields(logrus.Fields{
"from": "client",
"client": clientConn.RemoteAddr(),
"server": serverConn.RemoteAddr(),
})
defer func() {
log.Debug("Stop receiving from client.")
close(clientErrCh)
}()
for {
packet, err := protocol.ParsePacket(clientConn)
if err != nil {
if utils.IsOKNetworkError(err) {
log.Debug("Client connection closed.")
return
}
log.WithError(err).Error("Failed to read client packet.")
clientErrCh <- err
return
}
switch pkt := packet.(type) {
case *protocol.Query:
e.Audit.OnQuery(e.Context, sessionCtx, common.Query{Query: pkt.Query()})
case *protocol.ChangeUser:
// MySQL protocol includes COM_CHANGE_USER command that allows to
// re-authenticate connection as a different user. It is not
// supported by mysql shell and most drivers but some drivers do
// provide it.
//
// We do not want to allow changing the connection user and instead
// force users to go through normal reconnect flow so log the
// attempt and close the client connection.
log.Warnf("Rejecting attempt to change user to %q for session %v.", pkt.User(), sessionCtx)
return
case *protocol.Quit:
return
case *protocol.InitDB:
e.Audit.EmitEvent(e.Context, makeInitDBEvent(sessionCtx, pkt))
case *protocol.CreateDB:
e.Audit.EmitEvent(e.Context, makeCreateDBEvent(sessionCtx, pkt))
case *protocol.DropDB:
e.Audit.EmitEvent(e.Context, makeDropDBEvent(sessionCtx, pkt))
case *protocol.ShutDown:
e.Audit.EmitEvent(e.Context, makeShutDownEvent(sessionCtx, pkt))
case *protocol.ProcessKill:
e.Audit.EmitEvent(e.Context, makeProcessKillEvent(sessionCtx, pkt))
case *protocol.Debug:
e.Audit.EmitEvent(e.Context, makeDebugEvent(sessionCtx, pkt))
case *protocol.Refresh:
e.Audit.EmitEvent(e.Context, makeRefreshEvent(sessionCtx, pkt))
case *protocol.StatementPreparePacket:
e.Audit.EmitEvent(e.Context, makeStatementPrepareEvent(sessionCtx, pkt))
case *protocol.StatementExecutePacket:
// TODO(greedy52) Number of parameters is required to parse
// parameters out of the packet. Parameter definitions are required
// to properly format the parameters for including in the audit
// log. Both number of parameters and parameter definitions can be
// obtained from the response of COM_STMT_PREPARE.
e.Audit.EmitEvent(e.Context, makeStatementExecuteEvent(sessionCtx, pkt))
case *protocol.StatementSendLongDataPacket:
e.Audit.EmitEvent(e.Context, makeStatementSendLongDataEvent(sessionCtx, pkt))
case *protocol.StatementClosePacket:
e.Audit.EmitEvent(e.Context, makeStatementCloseEvent(sessionCtx, pkt))
case *protocol.StatementResetPacket:
e.Audit.EmitEvent(e.Context, makeStatementResetEvent(sessionCtx, pkt))
case *protocol.StatementFetchPacket:
e.Audit.EmitEvent(e.Context, makeStatementFetchEvent(sessionCtx, pkt))
case *protocol.StatementBulkExecutePacket:
// TODO(greedy52) Number of parameters and parameter definitions
// are required. See above comments for StatementExecutePacket.
e.Audit.EmitEvent(e.Context, makeStatementBulkExecuteEvent(sessionCtx, pkt))
}
_, err = protocol.WritePacket(packet.Bytes(), serverConn)
if err != nil {
log.WithError(err).Error("Failed to write server packet.")
clientErrCh <- err
return
}
}
}
// receiveFromServer relays protocol messages received from MySQL database
// to MySQL client.
func (e *Engine) receiveFromServer(serverConn, clientConn net.Conn, serverErrCh chan<- error) {
log := e.Log.WithFields(logrus.Fields{
"from": "server",
"client": clientConn.RemoteAddr(),
"server": serverConn.RemoteAddr(),
})
defer func() {
log.Debug("Stop receiving from server.")
close(serverErrCh)
}()
for {
packet, _, err := protocol.ReadPacket(serverConn)
if err != nil {
if utils.IsOKNetworkError(err) {
log.Debug("Server connection closed.")
return
}
log.WithError(err).Error("Failed to read server packet.")
serverErrCh <- err
return
}
_, err = protocol.WritePacket(packet, clientConn)
if err != nil {
log.WithError(err).Error("Failed to write client packet.")
serverErrCh <- err
return
}
}
}
// makeAcquireSemaphoreConfig builds parameters for acquiring a semaphore
// for connecting to a MySQL Cloud SQL instance for this session.
func (e *Engine) makeAcquireSemaphoreConfig(sessionCtx *common.Session) services.AcquireSemaphoreWithRetryConfig {
return services.AcquireSemaphoreWithRetryConfig{
Service: e.AuthClient,
// The semaphore will serialize connections to the database as specific
// user. If we fail to release the lock for some reason, it will expire
// in a minute anyway.
Request: types.AcquireSemaphoreRequest{
SemaphoreKind: "gcp-mysql-token",
SemaphoreName: fmt.Sprintf("%v-%v", sessionCtx.Database.GetName(), sessionCtx.DatabaseUser),
MaxLeases: 1,
Expires: e.Clock.Now().Add(time.Minute),
},
// If multiple connections are being established simultaneously to the
// same database as the same user, retry for a few seconds.
Retry: retryutils.LinearConfig{
Step: time.Second,
Max: time.Second,
Clock: e.Clock,
},
}
}
// newGCPTLSDialer returns a TLS dialer configured to connect to the Cloud Proxy
// port rather than the default MySQL port.
func newGCPTLSDialer(tlsConfig *tls.Config) client.Dialer {
return func(ctx context.Context, network, address string) (net.Conn, error) {
// Workaround issue generating ephemeral certificates for secure connections
// by creating a TLS connection to the Cloud Proxy port overriding the
// MySQL client's connection. MySQL on the default port does not trust
// the ephemeral certificate's CA but Cloud Proxy does.
host, port, err := net.SplitHostPort(address)
if err == nil && port == gcpSQLListenPort {
address = net.JoinHostPort(host, gcpSQLProxyListenPort)
}
tlsDialer := tls.Dialer{Config: tlsConfig}
return tlsDialer.DialContext(ctx, network, address)
}
}
// FetchMySQLVersion connects to MySQL database and tries to read the handshake packet and return the version.
// In case of error the message returned by the database is propagated in returned error.
func FetchMySQLVersion(ctx context.Context, database types.Database) (string, error) {
var dialer client.Dialer
if database.IsCloudSQL() {
dialer = newGCPTLSDialer(&tls.Config{})
} else {
var nd net.Dialer
dialer = nd.DialContext
}
return protocol.FetchMySQLVersionInternal(ctx, dialer, database.GetURI())
}
const (
// gcpSQLListenPort is the port used by Cloud SQL MySQL instances.
gcpSQLListenPort = "3306"
// gcpSQLProxyListenPort is the port used by Cloud Proxy for MySQL instances.
gcpSQLProxyListenPort = "3307"
)