Add CockroachDB support (#8505)

This commit is contained in:
Roman Tkachenko 2021-10-12 14:30:59 -07:00 committed by GitHub
parent 01ced111f4
commit 36998cf566
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 309 additions and 54 deletions

View file

@ -989,6 +989,19 @@ func (c *Config) MySQLProxyHostPort() (string, int) {
return webProxyHost, defaults.MySQLListenPort
}
// DatabaseProxyHostPort returns proxy connection endpoint for the database.
func (c *Config) DatabaseProxyHostPort(db tlsca.RouteToDatabase) (string, int) {
switch db.Protocol {
case defaults.ProtocolPostgres, defaults.ProtocolCockroachDB:
return c.PostgresProxyHostPort()
case defaults.ProtocolMySQL:
return c.MySQLProxyHostPort()
case defaults.ProtocolMongoDB:
return c.WebProxyHostPort()
}
return c.WebProxyHostPort()
}
// ProxyHost returns the hostname of the proxy server (without any port numbers)
func ProxyHost(proxyHost string) string {
host, _, err := net.SplitHostPort(proxyHost)

View file

@ -0,0 +1,51 @@
/*
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 postgres
import (
"fmt"
"net"
"strconv"
"strings"
"github.com/gravitational/teleport/lib/client/db/profile"
)
// GetConnString returns formatted Postgres connection string for the profile.
func GetConnString(c profile.ConnectProfile) string {
connStr := "postgres://"
if c.User != "" {
connStr += c.User + "@"
}
connStr += net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
if c.Database != "" {
connStr += "/" + c.Database
}
params := []string{
fmt.Sprintf("sslrootcert=%v", c.CACertPath),
fmt.Sprintf("sslcert=%v", c.CertPath),
fmt.Sprintf("sslkey=%v", c.KeyPath),
}
if c.Insecure {
params = append(params,
fmt.Sprintf("sslmode=%v", SSLModeVerifyCA))
} else {
params = append(params,
fmt.Sprintf("sslmode=%v", SSLModeVerifyFull))
}
return fmt.Sprintf("%v?%v", connStr, strings.Join(params, "&"))
}

View file

@ -0,0 +1,85 @@
/*
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 postgres
import (
"testing"
"github.com/gravitational/teleport/lib/client/db/profile"
"github.com/stretchr/testify/require"
)
// TestConnString verifies creating Postgres connection string from profile.
func TestConnString(t *testing.T) {
const (
host = "localhost"
port = 5432
caPath = "/tmp/ca"
certPath = "/tmp/cert"
keyPath = "/tmp/key"
)
tests := []struct {
name string
user string
database string
insecure bool
out string
}{
{
name: "default settings",
out: "postgres://localhost:5432?sslrootcert=/tmp/ca&sslcert=/tmp/cert&sslkey=/tmp/key&sslmode=verify-full",
},
{
name: "insecure",
insecure: true,
out: "postgres://localhost:5432?sslrootcert=/tmp/ca&sslcert=/tmp/cert&sslkey=/tmp/key&sslmode=verify-ca",
},
{
name: "user set",
user: "alice",
out: "postgres://alice@localhost:5432?sslrootcert=/tmp/ca&sslcert=/tmp/cert&sslkey=/tmp/key&sslmode=verify-full",
},
{
name: "database set",
database: "test",
out: "postgres://localhost:5432/test?sslrootcert=/tmp/ca&sslcert=/tmp/cert&sslkey=/tmp/key&sslmode=verify-full",
},
{
name: "user and database set",
user: "alice",
database: "test",
out: "postgres://alice@localhost:5432/test?sslrootcert=/tmp/ca&sslcert=/tmp/cert&sslkey=/tmp/key&sslmode=verify-full",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require.Equal(t, test.out, GetConnString(profile.ConnectProfile{
Host: host,
Port: port,
User: test.user,
Database: test.database,
Insecure: test.insecure,
CACertPath: caPath,
CertPath: certPath,
KeyPath: keyPath,
}))
})
}
}

View file

@ -68,7 +68,17 @@ func add(tc *client.TeleportClient, db tlsca.RouteToDatabase, clientProfile clie
default:
return nil, trace.BadParameter("unknown database protocol: %q", db)
}
connectProfile := profile.ConnectProfile{
connectProfile := New(tc, db, clientProfile, host, port)
err := profileFile.Upsert(connectProfile)
if err != nil {
return nil, trace.Wrap(err)
}
return &connectProfile, nil
}
// New makes a new database connection profile.
func New(tc *client.TeleportClient, db tlsca.RouteToDatabase, clientProfile client.ProfileStatus, host string, port int) profile.ConnectProfile {
return profile.ConnectProfile{
Name: profileName(tc.SiteName, db.ServiceName),
Host: host,
Port: port,
@ -79,11 +89,6 @@ func add(tc *client.TeleportClient, db tlsca.RouteToDatabase, clientProfile clie
CertPath: clientProfile.DatabaseCertPath(db.ServiceName),
KeyPath: clientProfile.KeyPath(),
}
err := profileFile.Upsert(connectProfile)
if err != nil {
return nil, trace.Wrap(err)
}
return &connectProfile, nil
}
// Env returns environment variables for the specified database profile.

View file

@ -22,6 +22,7 @@ import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/gravitational/teleport/api/identityfile"
@ -61,12 +62,16 @@ const (
// configuring a MongoDB database for mutual TLS authentication.
FormatMongo Format = "mongodb"
// FormatCockroach produces CA and key pair in the format suitable for
// configuring a CockroachDB database for mutual TLS.
FormatCockroach Format = "cockroachdb"
// DefaultFormat is what Teleport uses by default
DefaultFormat = FormatFile
)
// KnownFormats is a list of all above formats.
var KnownFormats = []Format{FormatFile, FormatOpenSSH, FormatTLS, FormatKubernetes, FormatDatabase, FormatMongo}
var KnownFormats = []Format{FormatFile, FormatOpenSSH, FormatTLS, FormatKubernetes, FormatDatabase, FormatMongo, FormatCockroach}
// WriteConfig holds the necessary information to write an identity file.
type WriteConfig struct {
@ -147,10 +152,18 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) {
return nil, trace.Wrap(err)
}
case FormatTLS, FormatDatabase:
case FormatTLS, FormatDatabase, FormatCockroach:
keyPath := cfg.OutputPath + ".key"
certPath := cfg.OutputPath + ".crt"
casPath := cfg.OutputPath + ".cas"
// CockroachDB expects files to be named ca.crt, node.crt and node.key.
if cfg.Format == FormatCockroach {
keyPath = filepath.Join(cfg.OutputPath, "node.key")
certPath = filepath.Join(cfg.OutputPath, "node.crt")
casPath = filepath.Join(cfg.OutputPath, "ca.crt")
}
filesWritten = append(filesWritten, keyPath, certPath, casPath)
if err := checkOverwrite(cfg.OverwriteDestination, filesWritten...); err != nil {
return nil, trace.Wrap(err)

View file

@ -473,6 +473,12 @@ const (
ProtocolMySQL = "mysql"
// ProtocolMongoDB is the MongoDB database protocol.
ProtocolMongoDB = "mongodb"
// ProtocolCockroachDB is the CockroachDB database protocol.
//
// Technically it's the same as the Postgres protocol but it's used to
// differentiate between Cockroach and Postgres databases e.g. when
// selecting a CLI client to use.
ProtocolCockroachDB = "cockroachdb"
)
// DatabaseProtocols is a list of all supported database protocols.
@ -480,6 +486,7 @@ var DatabaseProtocols = []string{
ProtocolPostgres,
ProtocolMySQL,
ProtocolMongoDB,
ProtocolCockroachDB,
}
const (

View file

@ -38,6 +38,13 @@ func DatabaseRoleMatchers(dbProtocol string, user, database string) services.Rol
return services.RoleMatchers{
&services.DatabaseUserMatcher{User: user},
}
case defaults.ProtocolCockroachDB:
// Cockroach uses the same wire protocol as Postgres but handling of
// databases is different and there's no way to prevent cross-database
// queries so only apply RBAC to db_users.
return services.RoleMatchers{
&services.DatabaseUserMatcher{User: user},
}
default:
return services.RoleMatchers{
&services.DatabaseUserMatcher{User: user},

View file

@ -23,7 +23,6 @@ import (
"net"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/srv/db/common"
"github.com/gravitational/teleport/lib/srv/db/common/role"
@ -183,7 +182,7 @@ func (e *Engine) checkAccess(ctx context.Context, sessionCtx *common.Session) er
}
dbRoleMatchers := role.DatabaseRoleMatchers(
defaults.ProtocolPostgres,
sessionCtx.Database.GetProtocol(),
sessionCtx.DatabaseUser,
sessionCtx.DatabaseName,
)

View file

@ -644,7 +644,7 @@ func (s *Server) dispatch(sessionCtx *common.Session, streamWriter events.Stream
return nil, trace.Wrap(err)
}
switch sessionCtx.Database.GetProtocol() {
case defaults.ProtocolPostgres:
case defaults.ProtocolPostgres, defaults.ProtocolCockroachDB:
return &postgres.Engine{
Auth: s.cfg.Auth,
Audit: audit,

View file

@ -320,9 +320,11 @@ func (a *AuthCommand) GenerateKeys() error {
// GenerateAndSignKeys generates a new keypair and signs it for role
func (a *AuthCommand) GenerateAndSignKeys(clusterAPI auth.ClientI) error {
switch {
case a.outputFormat == identityfile.FormatDatabase || a.outputFormat == identityfile.FormatMongo:
switch a.outputFormat {
case identityfile.FormatDatabase, identityfile.FormatMongo, identityfile.FormatCockroach:
return a.generateDatabaseKeys(clusterAPI)
}
switch {
case a.genUser != "" && a.genHost == "":
return a.generateUserKeys(clusterAPI)
case a.genUser == "" && a.genHost != "":
@ -425,6 +427,12 @@ func (a *AuthCommand) generateDatabaseKeysForKey(clusterAPI auth.ClientI, key *c
if len(principals) == 0 {
return trace.BadParameter("at least one hostname must be specified via --host flag")
}
// For CockroachDB node certificates, CommonName must be "node":
//
// https://www.cockroachlabs.com/docs/v21.1/cockroach-cert#node-key-and-certificates
if a.outputFormat == identityfile.FormatCockroach {
principals = append([]string{"node"}, principals...)
}
subject := pkix.Name{CommonName: principals[0]}
if a.outputFormat == identityfile.FormatMongo {
// Include Organization attribute in MongoDB certificates as well.
@ -485,6 +493,11 @@ func (a *AuthCommand) generateDatabaseKeysForKey(clusterAPI auth.ClientI, key *c
"files": strings.Join(filesWritten, ", "),
"output": a.output,
})
case identityfile.FormatCockroach:
cockroachAuthSignTpl.Execute(os.Stdout, map[string]interface{}{
"files": strings.Join(filesWritten, ", "),
"output": a.output,
})
}
return nil
}
@ -521,6 +534,15 @@ net:
mode: requireTLS
certificateKeyFile: /path/to/{{.output}}.crt
CAFile: /path/to/{{.output}}.cas
`))
cockroachAuthSignTpl = template.Must(template.New("").Parse(`Database credentials have been written to {{.files}}.
To enable mutual TLS on your CockroachDB server, point it to the certs
directory using --certs-dir flag:
cockroach start \
--certs-dir={{.output}} \
# other flags...
`))
)

View file

@ -376,8 +376,13 @@ func TestGenerateDatabaseKeys(t *testing.T) {
name string
inFormat identityfile.Format
inHost string
inOutDir string
inOutFile string
outSubject pkix.Name
outServerNames []string
outKeyFile string
outCertFile string
outCAFile string
outKey []byte
outCert []byte
outCA []byte
@ -386,8 +391,13 @@ func TestGenerateDatabaseKeys(t *testing.T) {
name: "database certificate",
inFormat: identityfile.FormatDatabase,
inHost: "postgres.example.com",
inOutDir: t.TempDir(),
inOutFile: "db",
outSubject: pkix.Name{CommonName: "postgres.example.com"},
outServerNames: []string{"postgres.example.com"},
outKeyFile: "db.key",
outCertFile: "db.crt",
outCAFile: "db.cas",
outKey: key.Priv,
outCert: certBytes,
outCA: caBytes,
@ -396,8 +406,13 @@ func TestGenerateDatabaseKeys(t *testing.T) {
name: "database certificate multiple SANs",
inFormat: identityfile.FormatDatabase,
inHost: "mysql.external.net,mysql.internal.net,192.168.1.1",
inOutDir: t.TempDir(),
inOutFile: "db",
outSubject: pkix.Name{CommonName: "mysql.external.net"},
outServerNames: []string{"mysql.external.net", "mysql.internal.net", "192.168.1.1"},
outKeyFile: "db.key",
outCertFile: "db.crt",
outCAFile: "db.cas",
outKey: key.Priv,
outCert: certBytes,
outCA: caBytes,
@ -406,17 +421,35 @@ func TestGenerateDatabaseKeys(t *testing.T) {
name: "mongodb certificate",
inFormat: identityfile.FormatMongo,
inHost: "mongo.example.com",
inOutDir: t.TempDir(),
inOutFile: "mongo",
outSubject: pkix.Name{CommonName: "mongo.example.com", Organization: []string{"example.com"}},
outServerNames: []string{"mongo.example.com"},
outCertFile: "mongo.crt",
outCAFile: "mongo.cas",
outCert: append(certBytes, key.Priv...),
outCA: caBytes,
},
{
name: "cockroachdb certificate",
inFormat: identityfile.FormatCockroach,
inHost: "localhost,roach1",
inOutDir: t.TempDir(),
outSubject: pkix.Name{CommonName: "node"},
outServerNames: []string{"node", "localhost", "roach1"}, // "node" principal should always be added
outKeyFile: "node.key",
outCertFile: "node.crt",
outCAFile: "ca.crt",
outKey: key.Priv,
outCert: certBytes,
outCA: caBytes,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ac := AuthCommand{
output: filepath.Join(t.TempDir(), "db"),
output: filepath.Join(test.inOutDir, test.inOutFile),
outputFormat: test.inFormat,
signOverwrite: true,
genHost: test.inHost,
@ -434,19 +467,19 @@ func TestGenerateDatabaseKeys(t *testing.T) {
require.Equal(t, test.outServerNames[0], authClient.dbCertsReq.ServerName)
if len(test.outKey) > 0 {
keyBytes, err := ioutil.ReadFile(ac.output + ".key")
keyBytes, err := ioutil.ReadFile(filepath.Join(test.inOutDir, test.outKeyFile))
require.NoError(t, err)
require.Equal(t, test.outKey, keyBytes, "keys match")
}
if len(test.outCert) > 0 {
certBytes, err := ioutil.ReadFile(ac.output + ".crt")
certBytes, err := ioutil.ReadFile(filepath.Join(test.inOutDir, test.outCertFile))
require.NoError(t, err)
require.Equal(t, test.outCert, certBytes, "certificates match")
}
if len(test.outCA) > 0 {
caBytes, err := ioutil.ReadFile(ac.output + ".cas")
caBytes, err := ioutil.ReadFile(filepath.Join(test.inOutDir, test.outCAFile))
require.NoError(t, err)
require.Equal(t, test.outCA, caBytes, "CA certificates match")
}

View file

@ -30,6 +30,7 @@ import (
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/client"
dbprofile "github.com/gravitational/teleport/lib/client/db"
"github.com/gravitational/teleport/lib/client/db/postgres"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/srv/alpnproxy"
"github.com/gravitational/teleport/lib/tlsca"
@ -227,7 +228,7 @@ func onDatabaseConfig(cf *CLIConf) error {
var host string
var port int
switch database.Protocol {
case defaults.ProtocolPostgres:
case defaults.ProtocolPostgres, defaults.ProtocolCockroachDB:
host, port = tc.PostgresProxyHostPort()
case defaults.ProtocolMySQL:
host, port = tc.MySQLProxyHostPort()
@ -324,6 +325,7 @@ func onDatabaseConnect(cf *CLIConf) error {
if err != nil {
return trace.Wrap(err)
}
log.Debug(cmd.String())
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
@ -449,6 +451,14 @@ func pickActiveDatabase(cf *CLIConf) (*tlsca.RouteToDatabase, error) {
}
for _, db := range profile.Databases {
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
}
}
@ -477,46 +487,54 @@ func getConnectCommand(cf *CLIConf, tc *client.TeleportClient, profile *client.P
opt(&options)
}
// In TLS routing mode a local proxy is started on demand so connect to it.
host, port := tc.DatabaseProxyHostPort(*db)
if options.localProxyPort != 0 && options.localProxyHost != "" {
host = options.localProxyHost
port = options.localProxyPort
}
switch db.Protocol {
case defaults.ProtocolPostgres:
return getPostgresCommand(db, profile.Cluster, cf.DatabaseUser, cf.DatabaseName, options), nil
return getPostgresCommand(tc, profile, db, host, port, options), nil
case defaults.ProtocolCockroachDB:
return getCockroachCommand(tc, profile, db, host, port, options), nil
case defaults.ProtocolMySQL:
return getMySQLCommand(db, profile.Cluster, cf.DatabaseUser, cf.DatabaseName, options), nil
return getMySQLCommand(profile, db, options), nil
case defaults.ProtocolMongoDB:
host, port := tc.WebProxyHostPort()
if options.localProxyPort != 0 && options.localProxyHost != "" {
host = options.localProxyHost
port = options.localProxyPort
}
return getMongoCommand(host, port, profile.DatabaseCertPath(db.ServiceName), options.caPath, cf.DatabaseName), nil
return getMongoCommand(profile, db, host, port, options), nil
}
return nil, trace.BadParameter("unsupported database protocol: %v", db)
}
func getPostgresCommand(db *tlsca.RouteToDatabase, cluster, user, name string, options connectionCommandOpts) *exec.Cmd {
connString := []string{fmt.Sprintf("service=%v-%v", cluster, db.ServiceName)}
if user != "" {
connString = append(connString, fmt.Sprintf("user=%v", user))
}
if name != "" {
connString = append(connString, fmt.Sprintf("dbname=%v", name))
}
if options.localProxyPort != 0 {
connString = append(connString, fmt.Sprintf("port=%v", options.localProxyPort))
}
if options.localProxyHost != "" {
connString = append(connString, fmt.Sprintf("host=%v", options.localProxyHost))
}
return exec.Command(postgresBin, strings.Join(connString, " "))
func getPostgresCommand(tc *client.TeleportClient, profile *client.ProfileStatus, db *tlsca.RouteToDatabase, host string, port int, options connectionCommandOpts) *exec.Cmd {
return exec.Command(postgresBin,
postgres.GetConnString(dbprofile.New(tc, *db, *profile, host, port)))
}
func getMySQLCommand(db *tlsca.RouteToDatabase, cluster, user, name string, options connectionCommandOpts) *exec.Cmd {
args := []string{fmt.Sprintf("--defaults-group-suffix=_%v-%v", cluster, db.ServiceName)}
if user != "" {
args = append(args, "--user", user)
func getCockroachCommand(tc *client.TeleportClient, profile *client.ProfileStatus, db *tlsca.RouteToDatabase, host string, port int, options connectionCommandOpts) *exec.Cmd {
// If cockroach CLI client is not available, fallback to psql.
if _, err := exec.LookPath(cockroachBin); err != nil {
log.Debugf("Couldn't find %q client in PATH, falling back to %q: %v.",
cockroachBin, postgresBin, err)
return exec.Command(postgresBin,
postgres.GetConnString(dbprofile.New(tc, *db, *profile, host, port)))
}
if name != "" {
args = append(args, "--database", name)
return exec.Command(cockroachBin, "sql", "--url",
postgres.GetConnString(dbprofile.New(tc, *db, *profile, host, port)))
}
func getMySQLCommand(profile *client.ProfileStatus, db *tlsca.RouteToDatabase, options connectionCommandOpts) *exec.Cmd {
args := []string{fmt.Sprintf("--defaults-group-suffix=_%v-%v", profile.Cluster, db.ServiceName)}
if db.Username != "" {
args = append(args, "--user", db.Username)
}
if db.Database != "" {
args = append(args, "--database", db.Database)
}
if options.localProxyPort != 0 {
@ -532,21 +550,21 @@ func getMySQLCommand(db *tlsca.RouteToDatabase, cluster, user, name string, opti
return exec.Command(mysqlBin, args...)
}
func getMongoCommand(host string, port int, certPath, caPath, name string) *exec.Cmd {
func getMongoCommand(profile *client.ProfileStatus, db *tlsca.RouteToDatabase, host string, port int, options connectionCommandOpts) *exec.Cmd {
args := []string{
"--host", host,
"--port", strconv.Itoa(port),
"--ssl",
"--sslPEMKeyFile", certPath,
"--sslPEMKeyFile", profile.DatabaseCertPath(db.ServiceName),
}
if caPath != "" {
if options.caPath != "" {
// caPath is set only if mongo connects to the Teleport Proxy via ALPN SNI Local Proxy
// and connection is terminated by proxy identity certificate.
args = append(args, []string{"--sslCAFile", caPath}...)
args = append(args, []string{"--sslCAFile", options.caPath}...)
}
if name != "" {
args = append(args, name)
if db.Database != "" {
args = append(args, db.Database)
}
return exec.Command(mongoBin, args...)
}
@ -561,6 +579,8 @@ const (
const (
// postgresBin is the Postgres client binary name.
postgresBin = "psql"
// cockroachBin is the Cockroach client binary name.
cockroachBin = "cockroach"
// mysqlBin is the MySQL client binary name.
mysqlBin = "mysql"
// mongoBin is the Mongo client binary name.

View file

@ -128,7 +128,7 @@ func toALPNProtocol(dbProtocol string) (alpncommon.Protocol, error) {
switch dbProtocol {
case defaults.ProtocolMySQL:
return alpncommon.ProtocolMySQL, nil
case defaults.ProtocolPostgres:
case defaults.ProtocolPostgres, defaults.ProtocolCockroachDB:
return alpncommon.ProtocolPostgres, nil
case defaults.ProtocolMongoDB:
return alpncommon.ProtocolMongoDB, nil