mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-31 20:39:37 +08:00
* Move the authorizer into its own package. The Authorizer and associated functions and constants have been moved into their own package `authz`. This will allow us to more easily create separate services for gRPC functionality so that we're not pushing as much into the `lib/auth` package. Though this PR is large, the only actual code change here is making `ContextForLocalUser` exported and changing the `permissions_test.go` so that it doesn't rely on the `lib/auth` package. * Use context functions. * Test and linting fixes. * Add in a temporary alias file to ensure e still compiles. * Use functions instead of var aliases for performance reasons. * Adding in godocs, cleaning up tests. * Fix tests. * Add a few more aliases.
1125 lines
36 KiB
Go
1125 lines
36 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 db
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"github.com/gravitational/teleport"
|
|
apidefaults "github.com/gravitational/teleport/api/defaults"
|
|
"github.com/gravitational/teleport/api/types"
|
|
apievents "github.com/gravitational/teleport/api/types/events"
|
|
"github.com/gravitational/teleport/lib/auth"
|
|
"github.com/gravitational/teleport/lib/authz"
|
|
clients "github.com/gravitational/teleport/lib/cloud"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/labels"
|
|
"github.com/gravitational/teleport/lib/limiter"
|
|
"github.com/gravitational/teleport/lib/reversetunnel"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/srv"
|
|
"github.com/gravitational/teleport/lib/srv/db/cassandra"
|
|
"github.com/gravitational/teleport/lib/srv/db/cloud"
|
|
"github.com/gravitational/teleport/lib/srv/db/cloud/users"
|
|
"github.com/gravitational/teleport/lib/srv/db/common"
|
|
"github.com/gravitational/teleport/lib/srv/db/dynamodb"
|
|
"github.com/gravitational/teleport/lib/srv/db/elasticsearch"
|
|
"github.com/gravitational/teleport/lib/srv/db/mongodb"
|
|
"github.com/gravitational/teleport/lib/srv/db/mysql"
|
|
"github.com/gravitational/teleport/lib/srv/db/postgres"
|
|
"github.com/gravitational/teleport/lib/srv/db/redis"
|
|
"github.com/gravitational/teleport/lib/srv/db/snowflake"
|
|
"github.com/gravitational/teleport/lib/srv/db/sqlserver"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
)
|
|
|
|
func init() {
|
|
common.RegisterEngine(cassandra.NewEngine, defaults.ProtocolCassandra)
|
|
common.RegisterEngine(elasticsearch.NewEngine, defaults.ProtocolElasticsearch)
|
|
common.RegisterEngine(mongodb.NewEngine, defaults.ProtocolMongoDB)
|
|
common.RegisterEngine(mysql.NewEngine, defaults.ProtocolMySQL)
|
|
common.RegisterEngine(postgres.NewEngine, defaults.ProtocolPostgres, defaults.ProtocolCockroachDB)
|
|
common.RegisterEngine(redis.NewEngine, defaults.ProtocolRedis)
|
|
common.RegisterEngine(snowflake.NewEngine, defaults.ProtocolSnowflake)
|
|
common.RegisterEngine(sqlserver.NewEngine, defaults.ProtocolSQLServer)
|
|
common.RegisterEngine(dynamodb.NewEngine, defaults.ProtocolDynamoDB)
|
|
}
|
|
|
|
// Config is the configuration for a database proxy server.
|
|
type Config struct {
|
|
// Clock used to control time.
|
|
Clock clockwork.Clock
|
|
// DataDir is the path to the data directory for the server.
|
|
DataDir string
|
|
// AuthClient is a client directly connected to the Auth server.
|
|
AuthClient *auth.Client
|
|
// AccessPoint is a caching client connected to the Auth Server.
|
|
AccessPoint auth.DatabaseAccessPoint
|
|
// Emitter is used to emit audit events.
|
|
Emitter apievents.Emitter
|
|
// StreamEmitter is a non-blocking audit events emitter.
|
|
StreamEmitter events.StreamEmitter
|
|
// NewAudit allows to override audit logger in tests.
|
|
NewAudit NewAuditFn
|
|
// TLSConfig is the *tls.Config for this server.
|
|
TLSConfig *tls.Config
|
|
// Limiter limits the number of connections per client IP.
|
|
Limiter *limiter.Limiter
|
|
// Authorizer is used to authorize requests coming from proxy.
|
|
Authorizer authz.Authorizer
|
|
// GetRotation returns the certificate rotation state.
|
|
GetRotation func(role types.SystemRole) (*types.Rotation, error)
|
|
// GetServerInfoFn returns function that returns database info for heartbeats.
|
|
GetServerInfoFn func(database types.Database) func() (types.Resource, error)
|
|
// Hostname is the hostname where this database server is running.
|
|
Hostname string
|
|
// HostID is the id of the host where this database server is running.
|
|
HostID string
|
|
// ResourceMatchers is a list of database resource matchers.
|
|
ResourceMatchers []services.ResourceMatcher
|
|
// AWSMatchers is a list of AWS databases matchers.
|
|
AWSMatchers []services.AWSMatcher
|
|
// AzureMatchers is a list of Azure databases matchers.
|
|
AzureMatchers []services.AzureMatcher
|
|
// Databases is a list of proxied databases from static configuration.
|
|
Databases types.Databases
|
|
// CloudLabels is a service that imports labels from a cloud provider. The labels are shared
|
|
// between all databases.
|
|
CloudLabels labels.Importer
|
|
// OnHeartbeat is called after every heartbeat. Used to update process state.
|
|
OnHeartbeat func(error)
|
|
// OnReconcile is called after each database resource reconciliation.
|
|
OnReconcile func(types.Databases)
|
|
// Auth is responsible for generating database auth tokens.
|
|
Auth common.Auth
|
|
// CADownloader automatically downloads root certs for cloud hosted databases.
|
|
CADownloader CADownloader
|
|
// LockWatcher is a lock watcher.
|
|
LockWatcher *services.LockWatcher
|
|
// CloudClients creates cloud API clients.
|
|
CloudClients clients.Clients
|
|
// CloudMeta fetches cloud metadata for cloud hosted databases.
|
|
CloudMeta *cloud.Metadata
|
|
// CloudIAM configures IAM for cloud hosted databases.
|
|
CloudIAM *cloud.IAM
|
|
// ConnectedProxyGetter gets the proxies teleport is connected to.
|
|
ConnectedProxyGetter *reversetunnel.ConnectedProxyGetter
|
|
// CloudUsers manage users for cloud hosted databases.
|
|
CloudUsers *users.Users
|
|
// discoveryResourceChecker performs some pre-checks when creating databases
|
|
// discovered by the discovery service.
|
|
discoveryResourceChecker discoveryResourceChecker
|
|
}
|
|
|
|
// NewAuditFn defines a function that creates an audit logger.
|
|
type NewAuditFn func(common.AuditConfig) (common.Audit, error)
|
|
|
|
// CheckAndSetDefaults makes sure the configuration has the minimum required
|
|
// to function.
|
|
func (c *Config) CheckAndSetDefaults(ctx context.Context) (err error) {
|
|
if c.Clock == nil {
|
|
c.Clock = clockwork.NewRealClock()
|
|
}
|
|
if c.DataDir == "" {
|
|
return trace.BadParameter("missing DataDir")
|
|
}
|
|
if c.AuthClient == nil {
|
|
return trace.BadParameter("missing AuthClient")
|
|
}
|
|
if c.AccessPoint == nil {
|
|
return trace.BadParameter("missing AccessPoint")
|
|
}
|
|
if c.StreamEmitter == nil {
|
|
return trace.BadParameter("missing StreamEmitter")
|
|
}
|
|
if c.Emitter == nil {
|
|
c.Emitter = c.AuthClient
|
|
}
|
|
if c.NewAudit == nil {
|
|
c.NewAudit = common.NewAudit
|
|
}
|
|
if c.Auth == nil {
|
|
c.Auth, err = common.NewAuth(common.AuthConfig{
|
|
AuthClient: c.AuthClient,
|
|
Clock: c.Clock,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
if c.Hostname == "" {
|
|
return trace.BadParameter("missing Hostname")
|
|
}
|
|
if c.HostID == "" {
|
|
return trace.BadParameter("missing HostID")
|
|
}
|
|
if c.TLSConfig == nil {
|
|
return trace.BadParameter("missing TLSConfig")
|
|
}
|
|
if c.Authorizer == nil {
|
|
return trace.BadParameter("missing Authorizer")
|
|
}
|
|
if c.GetRotation == nil {
|
|
return trace.BadParameter("missing GetRotation")
|
|
}
|
|
if c.CADownloader == nil {
|
|
c.CADownloader = NewRealDownloader()
|
|
}
|
|
if c.LockWatcher == nil {
|
|
return trace.BadParameter("missing LockWatcher")
|
|
}
|
|
if c.CloudClients == nil {
|
|
c.CloudClients = clients.NewClients()
|
|
}
|
|
if c.CloudMeta == nil {
|
|
c.CloudMeta, err = cloud.NewMetadata(cloud.MetadataConfig{
|
|
Clients: c.CloudClients,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
if c.CloudIAM == nil {
|
|
c.CloudIAM, err = cloud.NewIAM(ctx, cloud.IAMConfig{
|
|
AccessPoint: c.AccessPoint,
|
|
Clients: c.CloudClients,
|
|
HostID: c.HostID,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
if c.Limiter == nil {
|
|
// Use default limiter if nothing is provided. Connection limiting will be disabled.
|
|
c.Limiter, err = limiter.NewLimiter(limiter.Config{})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
if c.ConnectedProxyGetter == nil {
|
|
c.ConnectedProxyGetter = reversetunnel.NewConnectedProxyGetter()
|
|
}
|
|
|
|
if c.CloudUsers == nil {
|
|
c.CloudUsers, err = users.NewUsers(users.Config{
|
|
Clients: c.CloudClients,
|
|
UpdateMeta: c.CloudMeta.Update,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
if c.discoveryResourceChecker == nil {
|
|
c.discoveryResourceChecker, err = newCloudCrednentialsChecker(ctx, c.CloudClients, c.ResourceMatchers)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Server is a database server. It accepts database client requests coming over
|
|
// reverse tunnel from Teleport proxy and proxies them to databases.
|
|
type Server struct {
|
|
// cfg is the database server configuration.
|
|
cfg Config
|
|
// closeContext is used to indicate the server is closing.
|
|
closeContext context.Context
|
|
// closeFunc is the cancel function of the close context.
|
|
closeFunc context.CancelFunc
|
|
// middleware extracts identity from client certificates.
|
|
middleware *auth.Middleware
|
|
// dynamicLabels contains dynamic labels for databases.
|
|
dynamicLabels map[string]*labels.Dynamic
|
|
// heartbeats holds heartbeats for database servers.
|
|
heartbeats map[string]*srv.Heartbeat
|
|
// watcher monitors changes to database resources.
|
|
watcher *services.DatabaseWatcher
|
|
// proxiedDatabases contains databases this server currently is proxying.
|
|
// Proxied databases are reconciled against monitoredDatabases below.
|
|
proxiedDatabases map[string]types.Database
|
|
// monitoredDatabases contains all cluster databases the proxied databases
|
|
// are reconciled against.
|
|
monitoredDatabases monitoredDatabases
|
|
// reconcileCh triggers reconciliation of proxied databases.
|
|
reconcileCh chan struct{}
|
|
// mu protects access to server infos and databases.
|
|
mu sync.RWMutex
|
|
// log is used for logging.
|
|
log *logrus.Entry
|
|
}
|
|
|
|
// monitoredDatabases is a collection of databases from different sources
|
|
// like configuration file, dynamic resources and imported from cloud.
|
|
//
|
|
// It's updated by respective watchers and is used for reconciling with the
|
|
// currently proxied databases.
|
|
type monitoredDatabases struct {
|
|
// static are databases from the agent's YAML configuration.
|
|
static types.Databases
|
|
// resources are databases created via CLI, API, or discovery service.
|
|
resources types.Databases
|
|
// cloud are databases detected by cloud watchers.
|
|
cloud types.Databases
|
|
// mu protects access to the fields.
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func (m *monitoredDatabases) setResources(databases types.Databases) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.resources = databases
|
|
}
|
|
|
|
func (m *monitoredDatabases) setCloud(databases types.Databases) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.cloud = databases
|
|
}
|
|
|
|
func (m *monitoredDatabases) isCloud(database types.Database) bool {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
for i := range m.cloud {
|
|
if m.cloud[i] == database {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *monitoredDatabases) isDiscoveryResource(database types.Database) bool {
|
|
return database.Origin() == types.OriginCloud && m.isResource(database)
|
|
}
|
|
|
|
func (m *monitoredDatabases) isResource(database types.Database) bool {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
for i := range m.resources {
|
|
if m.resources[i] == database {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *monitoredDatabases) get() types.ResourcesWithLabelsMap {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return append(append(m.static, m.resources...), m.cloud...).AsResources().ToMap()
|
|
}
|
|
|
|
// New returns a new database server.
|
|
func New(ctx context.Context, config Config) (*Server, error) {
|
|
if err := common.CheckEngines(defaults.DatabaseProtocols...); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
err := config.CheckAndSetDefaults(ctx)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clustername, err := config.AccessPoint.GetClusterName()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
server := &Server{
|
|
cfg: config,
|
|
log: logrus.WithField(trace.Component, teleport.ComponentDatabase),
|
|
closeContext: ctx,
|
|
closeFunc: cancel,
|
|
dynamicLabels: make(map[string]*labels.Dynamic),
|
|
heartbeats: make(map[string]*srv.Heartbeat),
|
|
proxiedDatabases: config.Databases.ToMap(),
|
|
monitoredDatabases: monitoredDatabases{
|
|
static: config.Databases,
|
|
},
|
|
reconcileCh: make(chan struct{}),
|
|
middleware: &auth.Middleware{
|
|
ClusterName: clustername.GetClusterName(),
|
|
AcceptedUsage: []string{teleport.UsageDatabaseOnly},
|
|
},
|
|
}
|
|
|
|
// Update TLS config to require client certificate.
|
|
server.cfg.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
|
|
server.cfg.TLSConfig.GetConfigForClient = getConfigForClient(
|
|
server.cfg.TLSConfig, server.cfg.AccessPoint, server.log,
|
|
// TODO: Remove UserCA in Teleport 11.
|
|
types.UserCA, types.DatabaseCA)
|
|
|
|
return server, nil
|
|
}
|
|
|
|
// startDatabase performs initialization actions for the provided database
|
|
// such as starting dynamic labels and initializing CA certificate.
|
|
func (s *Server) startDatabase(ctx context.Context, database types.Database) error {
|
|
// For cloud-hosted databases (RDS, Redshift, GCP), try to automatically
|
|
// download a CA certificate.
|
|
// TODO(r0mant): This should ideally become a part of cloud metadata service.
|
|
if err := s.initCACert(ctx, database); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
// Update cloud metadata if it's a cloud hosted database on a best-effort
|
|
// basis.
|
|
if err := s.cfg.CloudMeta.Update(ctx, database); err != nil {
|
|
s.log.Warnf("Failed to fetch cloud metadata for %v: %v.", database, err)
|
|
}
|
|
// Attempts to fetch cloud metadata and configure IAM for cloud-hosted
|
|
// databases on a best-effort basis.
|
|
//
|
|
// TODO(r0mant): It may also make sense to auto-configure IAM upon getting
|
|
// access denied error at connection time in case this fails or the policy
|
|
// gets removed off-band.
|
|
if err := s.cfg.CloudIAM.Setup(ctx, database); err != nil {
|
|
s.log.Warnf("Failed to auto-configure IAM for %v: %v.", database, err)
|
|
}
|
|
// Start a goroutine that will be updating database's command labels (if any)
|
|
// on the defined schedule.
|
|
if err := s.startDynamicLabels(ctx, database); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := fetchMySQLVersion(ctx, database); err != nil {
|
|
// Log, but do not fail. We will fetch the version later.
|
|
s.log.Warnf("Failed to fetch the MySQL version for %s: %v", database.GetName(), err)
|
|
}
|
|
// Heartbeat will periodically report the presence of this proxied database
|
|
// to the auth server.
|
|
if err := s.startHeartbeat(ctx, database); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
// Setup managed users for database.
|
|
if err := s.cfg.CloudUsers.Setup(ctx, database); err != nil {
|
|
s.log.WithError(err).Warnf("Failed to setup users for %v.", database.GetName())
|
|
}
|
|
|
|
s.log.Debugf("Started %v.", database)
|
|
return nil
|
|
}
|
|
|
|
// stopDatabase uninitializes the database with the specified name.
|
|
func (s *Server) stopDatabase(ctx context.Context, name string) error {
|
|
s.stopDynamicLabels(name)
|
|
if err := s.stopHeartbeat(name); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
s.log.Debugf("Stopped database %q.", name)
|
|
return nil
|
|
}
|
|
|
|
// deleteDatabaseServer deletes database server for the specified database.
|
|
func (s *Server) deleteDatabaseServer(ctx context.Context, name string) error {
|
|
err := s.cfg.AuthClient.DeleteDatabaseServer(ctx, apidefaults.Namespace, s.cfg.HostID, name)
|
|
if err != nil && !trace.IsNotFound(err) {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// startDynamicLabels starts dynamic labels for the database if it has them.
|
|
func (s *Server) startDynamicLabels(ctx context.Context, database types.Database) error {
|
|
if len(database.GetDynamicLabels()) == 0 {
|
|
return nil // Nothing to do.
|
|
}
|
|
dynamic, err := labels.NewDynamic(ctx, &labels.DynamicConfig{
|
|
Labels: database.GetDynamicLabels(),
|
|
Log: s.log,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
dynamic.Sync()
|
|
dynamic.Start()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.dynamicLabels[database.GetName()] = dynamic
|
|
return nil
|
|
}
|
|
|
|
// getDynamicLabels returns dynamic labels for the specified database.
|
|
func (s *Server) getDynamicLabels(name string) *labels.Dynamic {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
dynamic, ok := s.dynamicLabels[name]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return dynamic
|
|
}
|
|
|
|
// stopDynamicLabels stops dynamic labels for the specified database.
|
|
func (s *Server) stopDynamicLabels(name string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
dynamic, ok := s.dynamicLabels[name]
|
|
if !ok {
|
|
return
|
|
}
|
|
delete(s.dynamicLabels, name)
|
|
dynamic.Close()
|
|
}
|
|
|
|
// registerDatabase initializes the provided database and adds it to the list
|
|
// of databases this server proxies.
|
|
func (s *Server) registerDatabase(ctx context.Context, database types.Database) error {
|
|
if err := s.startDatabase(ctx, database); err != nil {
|
|
// Cleanup in case database was initialized only partially.
|
|
if errStop := s.stopDatabase(ctx, database.GetName()); errStop != nil {
|
|
return trace.NewAggregate(err, errStop)
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.proxiedDatabases[database.GetName()] = database
|
|
return nil
|
|
}
|
|
|
|
// updateDatabase updates database that is already registered.
|
|
func (s *Server) updateDatabase(ctx context.Context, database types.Database) error {
|
|
// Stop heartbeat and dynamic labels before starting new ones.
|
|
if err := s.stopDatabase(ctx, database.GetName()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if err := s.registerDatabase(ctx, database); err != nil {
|
|
// If we failed to re-register, don't keep proxying the old database.
|
|
if errUnregister := s.unregisterDatabase(ctx, database); errUnregister != nil {
|
|
return trace.NewAggregate(err, errUnregister)
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unregisterDatabase uninitializes the specified database and removes it from
|
|
// the list of databases this server proxies.
|
|
func (s *Server) unregisterDatabase(ctx context.Context, database types.Database) error {
|
|
// Deconfigure IAM for the cloud database.
|
|
if err := s.cfg.CloudIAM.Teardown(ctx, database); err != nil {
|
|
s.log.Warnf("Failed to teardown IAM for %v: %v.", database, err)
|
|
}
|
|
// Stop heartbeat, labels, etc.
|
|
if err := s.stopProxyingAndDeleteDatabase(ctx, database); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
|
|
}
|
|
|
|
// stopProxyingDatabase winds down the proxied database instance by stopping
|
|
// its heartbeat and dynamic labels and unregistering it from the list of
|
|
// proxied databases.
|
|
func (s *Server) stopProxyingDatabase(ctx context.Context, database types.Database) error {
|
|
// Stop heartbeat and dynamic labels updates.
|
|
if err := s.stopDatabase(ctx, database.GetName()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.proxiedDatabases, database.GetName())
|
|
return nil
|
|
}
|
|
|
|
// stopProxyingAndDeleteDatabase stops and deletes the database, then
|
|
// unregisters it from the list of proxied databases.
|
|
func (s *Server) stopProxyingAndDeleteDatabase(ctx context.Context, database types.Database) error {
|
|
// Stop heartbeat and dynamic labels updates.
|
|
if err := s.stopDatabase(ctx, database.GetName()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
// Heartbeat is stopped but if we don't remove this database server,
|
|
// it can linger for up to ~10m until its TTL expires.
|
|
if err := s.deleteDatabaseServer(ctx, database.GetName()); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.proxiedDatabases, database.GetName())
|
|
return nil
|
|
}
|
|
|
|
// getProxiedDatabases returns a list of all databases this server is proxying.
|
|
func (s *Server) getProxiedDatabases() (databases types.Databases) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, database := range s.proxiedDatabases {
|
|
databases = append(databases, database)
|
|
}
|
|
return databases
|
|
}
|
|
|
|
// startHeartbeat starts the registration heartbeat to the auth server.
|
|
func (s *Server) startHeartbeat(ctx context.Context, database types.Database) error {
|
|
heartbeat, err := srv.NewHeartbeat(srv.HeartbeatConfig{
|
|
Context: s.closeContext,
|
|
Component: teleport.ComponentDatabase,
|
|
Mode: srv.HeartbeatModeDB,
|
|
Announcer: s.cfg.AccessPoint,
|
|
GetServerInfo: s.getServerInfoFunc(database),
|
|
KeepAlivePeriod: apidefaults.ServerKeepAliveTTL(),
|
|
AnnouncePeriod: apidefaults.ServerAnnounceTTL/2 + utils.RandomDuration(apidefaults.ServerAnnounceTTL/10),
|
|
CheckPeriod: defaults.HeartbeatCheckPeriod,
|
|
ServerTTL: apidefaults.ServerAnnounceTTL,
|
|
OnHeartbeat: s.cfg.OnHeartbeat,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
go heartbeat.Run()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.heartbeats[database.GetName()] = heartbeat
|
|
return nil
|
|
}
|
|
|
|
// stopHeartbeat stops the heartbeat for the specified database.
|
|
func (s *Server) stopHeartbeat(name string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
heartbeat, ok := s.heartbeats[name]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
delete(s.heartbeats, name)
|
|
return heartbeat.Close()
|
|
}
|
|
|
|
// getServerInfoFunc returns function that the heartbeater uses to report the
|
|
// provided database to the auth server.
|
|
//
|
|
// It can be overridden by GetServerInfoFn from config by tests.
|
|
func (s *Server) getServerInfoFunc(database types.Database) func() (types.Resource, error) {
|
|
if s.cfg.GetServerInfoFn != nil {
|
|
return s.cfg.GetServerInfoFn(database)
|
|
}
|
|
return func() (types.Resource, error) {
|
|
return s.getServerInfo(database)
|
|
}
|
|
}
|
|
|
|
// getServerInfo returns up-to-date database resource e.g. with updated dynamic
|
|
// labels.
|
|
func (s *Server) getServerInfo(database types.Database) (types.Resource, error) {
|
|
// Make sure to return a new object, because it gets cached by
|
|
// heartbeat and will always compare as equal otherwise.
|
|
s.mu.RLock()
|
|
copy := database.Copy()
|
|
s.mu.RUnlock()
|
|
// Update dynamic labels if the database has them.
|
|
labels := s.getDynamicLabels(copy.GetName())
|
|
if labels != nil {
|
|
copy.SetDynamicLabels(labels.Get())
|
|
}
|
|
if s.cfg.CloudLabels != nil {
|
|
s.cfg.CloudLabels.Apply(copy)
|
|
}
|
|
expires := s.cfg.Clock.Now().UTC().Add(apidefaults.ServerAnnounceTTL)
|
|
server, err := types.NewDatabaseServerV3(types.Metadata{
|
|
Name: copy.GetName(),
|
|
Expires: &expires,
|
|
}, types.DatabaseServerSpecV3{
|
|
Version: teleport.Version,
|
|
Hostname: s.cfg.Hostname,
|
|
HostID: s.cfg.HostID,
|
|
Rotation: s.getRotationState(),
|
|
Database: copy,
|
|
ProxyIDs: s.cfg.ConnectedProxyGetter.GetProxyIDs(),
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return server, nil
|
|
}
|
|
|
|
// getRotationState is a helper to return this server's CA rotation state.
|
|
func (s *Server) getRotationState() types.Rotation {
|
|
rotation, err := s.cfg.GetRotation(types.RoleDatabase)
|
|
if err != nil && !trace.IsNotFound(err) {
|
|
s.log.WithError(err).Warn("Failed to get rotation state.")
|
|
}
|
|
if rotation != nil {
|
|
return *rotation
|
|
}
|
|
return types.Rotation{}
|
|
}
|
|
|
|
// Start starts proxying all server's registered databases.
|
|
func (s *Server) Start(ctx context.Context) (err error) {
|
|
// Start IAM service that will be configuring IAM auth for databases.
|
|
if err := s.cfg.CloudIAM.Start(ctx); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Start cloud users that will be monitoring cloud users.
|
|
go s.cfg.CloudUsers.Start(ctx, s.getProxiedDatabases)
|
|
|
|
// Start heartbeating the Database Service itself.
|
|
if err := s.startServiceHeartbeat(); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Register all databases from static configuration.
|
|
for _, database := range s.cfg.Databases {
|
|
if err := s.registerDatabase(ctx, database); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Start reconciler that will be reconciling proxied databases with
|
|
// database resources and cloud instances.
|
|
if err := s.startReconciler(ctx); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Start watcher that will be dynamically (un-)registering
|
|
// proxied databases based on the database resources.
|
|
if s.watcher, err = s.startResourceWatcher(ctx); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Start watcher that will be monitoring cloud provider databases
|
|
// according to the server's selectors.
|
|
if err := s.startCloudWatcher(ctx); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Start the cloud-based databases CA renewer.
|
|
go s.startCARenewer(ctx)
|
|
|
|
return nil
|
|
}
|
|
|
|
// startServiceHeartbeat sends the current DatabaseService server info.
|
|
func (s *Server) startServiceHeartbeat() error {
|
|
getDatabaseServiceServerInfo := func() (types.Resource, error) {
|
|
expires := s.cfg.Clock.Now().UTC().Add(apidefaults.ServerAnnounceTTL)
|
|
resource, err := types.NewDatabaseServiceV1(types.Metadata{
|
|
Name: s.cfg.HostID,
|
|
Namespace: apidefaults.Namespace,
|
|
Expires: &expires,
|
|
}, types.DatabaseServiceSpecV1{
|
|
ResourceMatchers: services.ResourceMatchersToTypes(s.cfg.ResourceMatchers),
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return resource, nil
|
|
}
|
|
|
|
heartbeat, err := srv.NewHeartbeat(srv.HeartbeatConfig{
|
|
Context: s.closeContext,
|
|
Component: teleport.ComponentDatabase,
|
|
Mode: srv.HeartbeatModeDatabaseService,
|
|
Announcer: s.cfg.AccessPoint,
|
|
GetServerInfo: getDatabaseServiceServerInfo,
|
|
KeepAlivePeriod: apidefaults.ServerKeepAliveTTL(),
|
|
AnnouncePeriod: apidefaults.ServerAnnounceTTL/2 + utils.RandomDuration(apidefaults.ServerAnnounceTTL/10),
|
|
CheckPeriod: defaults.HeartbeatCheckPeriod,
|
|
ServerTTL: apidefaults.ServerAnnounceTTL,
|
|
OnHeartbeat: s.cfg.OnHeartbeat,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
go func() {
|
|
if err := heartbeat.Run(); err != nil {
|
|
s.log.WithError(err).Error("Heartbeat ended with error")
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// Close stops proxying all server's databases and frees up other resources.
|
|
func (s *Server) Close() error {
|
|
return trace.Wrap(s.close(s.closeContext))
|
|
}
|
|
|
|
// Shutdown performs a graceful shutdown.
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
// TODO wait active connections.
|
|
return trace.Wrap(s.close(ctx))
|
|
}
|
|
|
|
func (s *Server) close(ctx context.Context) error {
|
|
var errors []error
|
|
// Stop proxying all databases.
|
|
for _, database := range s.getProxiedDatabases() {
|
|
if services.ShouldDeleteServerHeartbeatsOnShutdown(ctx) {
|
|
errors = append(errors, trace.Wrap(s.stopProxyingAndDeleteDatabase(ctx, database)))
|
|
} else {
|
|
errors = append(errors, trace.Wrap(s.stopProxyingDatabase(ctx, database)))
|
|
}
|
|
}
|
|
// Signal to all goroutines to stop.
|
|
s.closeFunc()
|
|
// Stop the database resource watcher.
|
|
if s.watcher != nil {
|
|
s.watcher.Close()
|
|
}
|
|
// Close all cloud clients.
|
|
errors = append(errors, s.cfg.Auth.Close())
|
|
return trace.NewAggregate(errors...)
|
|
}
|
|
|
|
// Wait will block while the server is running.
|
|
func (s *Server) Wait() error {
|
|
<-s.closeContext.Done()
|
|
if err := s.closeContext.Err(); err != nil && err != context.Canceled {
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ForceHeartbeat is used by tests to force-heartbeat all registered databases.
|
|
func (s *Server) ForceHeartbeat() error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for name, heartbeat := range s.heartbeats {
|
|
s.log.Debugf("Forcing heartbeat for %q.", name)
|
|
if err := heartbeat.ForceSend(time.Second); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HandleConnection accepts the connection coming over reverse tunnel,
|
|
// upgrades it to TLS, extracts identity information from it, performs
|
|
// authorization and dispatches to the appropriate database engine.
|
|
func (s *Server) HandleConnection(conn net.Conn) {
|
|
log := s.log.WithField("addr", conn.RemoteAddr())
|
|
log.Debug("Accepted connection.")
|
|
// Upgrade the connection to TLS since the other side of the reverse
|
|
// tunnel connection (proxy) will initiate a handshake.
|
|
tlsConn := tls.Server(conn, s.cfg.TLSConfig)
|
|
// Make sure to close the upgraded connection, not "conn", otherwise
|
|
// the other side may not detect that connection has closed.
|
|
defer tlsConn.Close()
|
|
// Perform the handshake explicitly, normally it should be performed
|
|
// on the first read/write but when the connection is passed over
|
|
// reverse tunnel it doesn't happen for some reason.
|
|
err := tlsConn.Handshake()
|
|
if err != nil {
|
|
log.WithError(err).Error("Failed to perform TLS handshake.")
|
|
return
|
|
}
|
|
// Now that the handshake has completed and the client has sent us a
|
|
// certificate, extract identity information from it.
|
|
ctx, err := s.middleware.WrapContextWithUser(s.closeContext, tlsConn)
|
|
if err != nil {
|
|
log.WithError(err).Error("Failed to extract identity from connection.")
|
|
return
|
|
}
|
|
// Dispatch the connection for processing by an appropriate database
|
|
// service.
|
|
err = s.handleConnection(ctx, tlsConn)
|
|
if err != nil && !utils.IsOKNetworkError(err) && !trace.IsAccessDenied(err) {
|
|
log.WithError(err).Error("Failed to handle connection.")
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleConnection(ctx context.Context, clientConn net.Conn) error {
|
|
sessionCtx, err := s.authorize(ctx)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Create a session tracker so that other services, such as
|
|
// the session upload completer, can track the session's lifetime.
|
|
cancelCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
// Create a session tracker so that other services, such as
|
|
// the session upload completer, can track the session's lifetime.
|
|
if err := s.trackSession(cancelCtx, sessionCtx); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
streamWriter, err := s.newStreamWriter(sessionCtx)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
defer func() {
|
|
// Close session stream in a goroutine since depending on session size
|
|
// it can take a while, and we don't want to block the client.
|
|
go func() {
|
|
// Use the server closing context to make sure that upload
|
|
// continues beyond the session lifetime.
|
|
err := streamWriter.Close(s.closeContext)
|
|
if err != nil {
|
|
sessionCtx.Log.WithError(err).Warn("Failed to close stream writer.")
|
|
}
|
|
}()
|
|
}()
|
|
engine, err := s.dispatch(sessionCtx, streamWriter, clientConn)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
s.log.Warnf("Recovered while handling DB connection from %v: %v.", clientConn.RemoteAddr(), r)
|
|
err = trace.BadParameter("failed to handle client connection")
|
|
}
|
|
if err != nil {
|
|
engine.SendError(err)
|
|
}
|
|
}()
|
|
|
|
// Wrap a client connection into monitor that auto-terminates
|
|
// idle connection and connection with expired cert.
|
|
clientConn, err = monitorConn(ctx, monitorConnConfig{
|
|
conn: clientConn,
|
|
lockWatcher: s.cfg.LockWatcher,
|
|
lockTargets: sessionCtx.LockTargets,
|
|
identity: sessionCtx.Identity,
|
|
checker: sessionCtx.Checker,
|
|
clock: s.cfg.Clock,
|
|
serverID: s.cfg.HostID,
|
|
authClient: s.cfg.AuthClient,
|
|
teleportUser: sessionCtx.Identity.Username,
|
|
emitter: s.cfg.Emitter,
|
|
log: s.log,
|
|
ctx: s.closeContext,
|
|
})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// TODO(jakule): LoginIP should be required starting from 10.0.
|
|
clientIP := sessionCtx.Identity.LoginIP
|
|
if clientIP != "" {
|
|
s.log.Debugf("Real client IP %s", clientIP)
|
|
|
|
var release func()
|
|
release, err = s.cfg.Limiter.RegisterRequestAndConnection(clientIP)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
defer release()
|
|
} else {
|
|
s.log.Debug("LoginIP is not set (Proxy Service has to be updated). Rate limiting is disabled.")
|
|
}
|
|
|
|
err = engine.HandleConnection(ctx, sessionCtx)
|
|
if err != nil {
|
|
connectionDiagnosticID := sessionCtx.Identity.ConnectionDiagnosticID
|
|
if connectionDiagnosticID != "" && trace.IsAccessDenied(err) {
|
|
_, diagErr := s.cfg.AuthClient.AppendDiagnosticTrace(ctx,
|
|
connectionDiagnosticID,
|
|
&types.ConnectionDiagnosticTrace{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_DATABASE_LOGIN,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: "Access denied when accessing Database. Please check the Error message for more information.",
|
|
Error: err.Error(),
|
|
},
|
|
)
|
|
|
|
if diagErr != nil {
|
|
return trace.Wrap(diagErr)
|
|
}
|
|
}
|
|
|
|
return trace.Wrap(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dispatch creates and initializes an appropriate database engine for the session.
|
|
func (s *Server) dispatch(sessionCtx *common.Session, streamWriter events.StreamWriter, clientConn net.Conn) (common.Engine, error) {
|
|
audit, err := s.cfg.NewAudit(common.AuditConfig{
|
|
Emitter: streamWriter,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
engine, err := s.createEngine(sessionCtx, audit)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if err := engine.InitializeConnection(clientConn, sessionCtx); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return engine, nil
|
|
}
|
|
|
|
// createEngine creates a new database engine based on the database protocol.
|
|
// An error is returned when a protocol is not supported.
|
|
func (s *Server) createEngine(sessionCtx *common.Session, audit common.Audit) (common.Engine, error) {
|
|
return common.GetEngine(sessionCtx.Database.GetProtocol(), common.EngineConfig{
|
|
Auth: s.cfg.Auth,
|
|
Audit: audit,
|
|
AuthClient: s.cfg.AuthClient,
|
|
CloudClients: s.cfg.CloudClients,
|
|
Context: s.closeContext,
|
|
Clock: s.cfg.Clock,
|
|
Log: sessionCtx.Log,
|
|
Users: s.cfg.CloudUsers,
|
|
DataDir: s.cfg.DataDir,
|
|
})
|
|
}
|
|
|
|
func (s *Server) authorize(ctx context.Context) (*common.Session, error) {
|
|
// Only allow local and remote identities to proxy to a database.
|
|
userType, err := authz.UserFromContext(ctx)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
switch userType.(type) {
|
|
case authz.LocalUser, authz.RemoteUser:
|
|
default:
|
|
return nil, trace.BadParameter("invalid identity: %T", userType)
|
|
}
|
|
// Extract authorizing context and identity of the user from the request.
|
|
authContext, err := s.cfg.Authorizer.Authorize(ctx)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
identity := authContext.Identity.GetIdentity()
|
|
s.log.Debugf("Client identity: %#v.", identity)
|
|
|
|
// TODO(anton): Move this into authorizer.Authorize when we can enable it for all protocols
|
|
if err := auth.CheckIPPinning(ctx, identity, authContext.Checker.PinSourceIP()); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// Fetch the requested database server.
|
|
var database types.Database
|
|
registeredDatabases := s.getProxiedDatabases()
|
|
for _, db := range registeredDatabases {
|
|
if db.GetName() == identity.RouteToDatabase.ServiceName {
|
|
database = db
|
|
break
|
|
}
|
|
}
|
|
if database == nil {
|
|
return nil, trace.NotFound("%q not found among registered databases: %v",
|
|
identity.RouteToDatabase.ServiceName, registeredDatabases)
|
|
}
|
|
s.log.Debugf("Will connect to database %q at %v.", database.GetName(),
|
|
database.GetURI())
|
|
id := uuid.New().String()
|
|
return &common.Session{
|
|
ID: id,
|
|
ClusterName: identity.RouteToCluster,
|
|
HostID: s.cfg.HostID,
|
|
Database: database,
|
|
Identity: identity,
|
|
DatabaseUser: identity.RouteToDatabase.Username,
|
|
DatabaseName: identity.RouteToDatabase.Database,
|
|
Checker: authContext.Checker,
|
|
StartupParameters: make(map[string]string),
|
|
Log: s.log.WithFields(logrus.Fields{
|
|
"id": id,
|
|
"db": database.GetName(),
|
|
}),
|
|
LockTargets: authContext.LockTargets(),
|
|
}, nil
|
|
}
|
|
|
|
// fetchMySQLVersion tries to connect to MySQL instance, read initial handshake package and extract
|
|
// the server version.
|
|
func fetchMySQLVersion(ctx context.Context, database types.Database) error {
|
|
if database.GetProtocol() != defaults.ProtocolMySQL || database.GetMySQLServerVersion() != "" {
|
|
return nil
|
|
}
|
|
|
|
// Try to extract the engine version for AWS metadata labels.
|
|
if database.IsRDS() || database.IsAzure() {
|
|
version := services.GetMySQLEngineVersion(database.GetMetadata().Labels)
|
|
if version != "" {
|
|
database.SetMySQLServerVersion(version)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 7*time.Second)
|
|
defer cancel()
|
|
|
|
version, err := mysql.FetchMySQLVersion(ctx, database)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
database.SetMySQLServerVersion(version)
|
|
|
|
return nil
|
|
}
|
|
|
|
// trackSession creates a new session tracker for the database session.
|
|
// While ctx is open, the session tracker's expiration will be extended
|
|
// on an interval. Once the ctx is closed, the session tracker's state
|
|
// will be updated to terminated.
|
|
func (s *Server) trackSession(ctx context.Context, sessionCtx *common.Session) error {
|
|
trackerSpec := types.SessionTrackerSpecV1{
|
|
SessionID: sessionCtx.ID,
|
|
Kind: string(types.DatabaseSessionKind),
|
|
State: types.SessionState_SessionStateRunning,
|
|
Hostname: sessionCtx.HostID,
|
|
DatabaseName: sessionCtx.Database.GetName(),
|
|
ClusterName: sessionCtx.ClusterName,
|
|
Login: sessionCtx.Identity.GetUserMetadata().Login,
|
|
Participants: []types.Participant{{
|
|
User: sessionCtx.Identity.Username,
|
|
}},
|
|
HostUser: sessionCtx.Identity.Username,
|
|
Created: s.cfg.Clock.Now(),
|
|
}
|
|
|
|
s.log.Debugf("Creating tracker for session %v", sessionCtx.ID)
|
|
tracker, err := srv.NewSessionTracker(ctx, trackerSpec, s.cfg.AuthClient)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
go func() {
|
|
if err := tracker.UpdateExpirationLoop(ctx, s.cfg.Clock); err != nil {
|
|
s.log.WithError(err).Warnf("Failed to update session tracker expiration for session %v", sessionCtx.ID)
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
if err := tracker.Close(s.closeContext); err != nil {
|
|
s.log.WithError(err).Debugf("Failed to close session tracker for session %v", sessionCtx.ID)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|