From a07c19bf8ca8a7e38e329692dab7ed18ecfb95b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Smoli=C5=84ski?= Date: Fri, 17 Mar 2023 12:08:50 +0100 Subject: [PATCH] Add Support for Oracle protocol --- api/types/database.go | 1 + api/utils/keypaths/keypaths.go | 17 ++- go.mod | 1 + go.sum | 2 + lib/client/db/database_certificates.go | 6 +- lib/client/db/dbcmd/dbcmd.go | 35 +++++ lib/client/db/dbcmd/dbcmd_test.go | 18 +++ lib/client/db/oracle/config.go | 115 ++++++++++++++++ lib/client/db/oracle/oracle.go | 144 +++++++++++++++++++++ lib/client/identityfile/identity.go | 141 ++++++++++++++++++-- lib/client/interfaces.go | 2 +- lib/client/profile.go | 30 +++++ lib/defaults/defaults.go | 5 + lib/service/service.go | 2 + lib/service/service_test.go | 4 + lib/services/database.go | 4 + lib/srv/alpnproxy/common/protocols.go | 7 + lib/srv/db/common/engines.go | 5 + lib/srv/db/common/enterprise/enterprise.go | 35 +++++ lib/srv/db/proxyserver.go | 4 + lib/utils/fs.go | 10 ++ tool/tctl/common/auth_command.go | 73 +++++++++-- tool/tsh/app.go | 13 +- tool/tsh/db.go | 60 ++++++++- tool/tsh/db_test.go | 6 + tool/tsh/proxy.go | 61 ++++++++- tool/tsh/proxy_test.go | 2 +- 27 files changed, 752 insertions(+), 51 deletions(-) create mode 100644 lib/client/db/oracle/config.go create mode 100644 lib/client/db/oracle/oracle.go create mode 100644 lib/srv/db/common/enterprise/enterprise.go diff --git a/api/types/database.go b/api/types/database.go index 4d31d7b81843e..b4b4b447b526b 100644 --- a/api/types/database.go +++ b/api/types/database.go @@ -511,6 +511,7 @@ func (d *DatabaseV3) CheckAndSetDefaults() error { if err := d.Metadata.CheckAndSetDefaults(); err != nil { return trace.Wrap(err) } + for key := range d.Spec.DynamicLabels { if !IsValidLabelKey(key) { return trace.BadParameter("database %q invalid label key: %q", d.GetName(), key) diff --git a/api/utils/keypaths/keypaths.go b/api/utils/keypaths/keypaths.go index 43dedabd2c4ee..5d30a69535537 100644 --- a/api/utils/keypaths/keypaths.go +++ b/api/utils/keypaths/keypaths.go @@ -60,6 +60,8 @@ const ( currentProfileFilename = "current-profile" // profileFileExt is the suffix of a profile file. profileFileExt = ".yaml" + // oracleWalletDirSuffix is the suffix of the oracle wallet database directory. + oracleWalletDirSuffix = "-wallet" ) // Here's the file layout of all these keypaths. @@ -88,9 +90,11 @@ const ( // │ ├── foo-db --> App access certs for user "foo" // │ │ ├── root --> App access certs for cluster "root" // │ │ │ ├── dbA-x509.pem --> TLS cert for database service "dbA" -// │ │ │ └── dbB-x509.pem --> TLS cert for database service "dbB" -// │ │ └── leaf --> App access certs for cluster "leaf" -// │ │ └── dbC-x509.pem --> TLS cert for database service "dbC" +// │ │ │ ├── dbB-x509.pem --> TLS cert for database service "dbB" +// │ │ │ └── dbC-wallet --> Oracle Client wallet Configuration directory. +// │ │ ├── leaf --> App access certs for cluster "leaf" +// │ │ │ └── dbC-x509.pem --> TLS cert for database service "dbC" +// │ │ └── proxy-localca.pem --> Self-signed TLS Routing local proxy CA // │ ├── foo-kube --> Kubernetes certs for user "foo" // │ | ├── root --> Kubernetes certs for Teleport cluster "root" // │ | │ ├── kubeA-kubeconfig --> standalone kubeconfig for Kubernetes cluster "kubeA" @@ -267,6 +271,13 @@ func DatabaseCertPath(baseDir, proxy, username, cluster, dbname string) string { return filepath.Join(DatabaseCertDir(baseDir, proxy, username, cluster), dbname+fileExtTLSCert) } +// DatabaseOracleWalletDirectory returns the path to the user's Oracle Wallet configuration directory. +// for the given proxy, cluster and database. +// /keys//-db//dbname-wallet/ +func DatabaseOracleWalletDirectory(baseDir, proxy, username, cluster, dbname string) string { + return filepath.Join(DatabaseCertDir(baseDir, proxy, username, cluster), dbname+oracleWalletDirSuffix) +} + // KubeDir returns the path to the user's kube directory // for the given proxy. // diff --git a/go.mod b/go.mod index 381c6c1ddbd60..63b2310908682 100644 --- a/go.mod +++ b/go.mod @@ -157,6 +157,7 @@ require ( sigs.k8s.io/controller-runtime v0.14.1 sigs.k8s.io/controller-tools v0.11.1 sigs.k8s.io/yaml v1.3.0 + software.sslmate.com/src/go-pkcs12 v0.2.0 ) // DO NOT UPDATE any of the following dependencies until the diff --git a/go.sum b/go.sum index ec19369746930..ce58f5dbda45b 100644 --- a/go.sum +++ b/go.sum @@ -1987,4 +1987,6 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= +software.sslmate.com/src/go-pkcs12 v0.2.0 h1:nlFkj7bTysH6VkC4fGphtjXRbezREPgrHuJG20hBGPE= +software.sslmate.com/src/go-pkcs12 v0.2.0/go.mod h1:23rNcYsMabIc1otwLpTkCCPwUq6kQsTyowttG/as0kQ= sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/lib/client/db/database_certificates.go b/lib/client/db/database_certificates.go index a9c5e18e5c387..1209b40465432 100644 --- a/lib/client/db/database_certificates.go +++ b/lib/client/db/database_certificates.go @@ -41,8 +41,8 @@ type GenerateDatabaseCertificatesRequest struct { IdentityFileWriter identityfile.ConfigWriter TTL time.Duration Key *client.Key - // JKSKeyStore is used to generate JKS keystore used for cassandra format. - JKSPassword string + // Password is used to generate JKS keystore used for cassandra format or Oracle wallet. + Password string } // GenerateDatabaseCertificates to be used by databases to set up mTLS authentication @@ -123,7 +123,7 @@ func GenerateDatabaseCertificates(ctx context.Context, req GenerateDatabaseCerti Format: req.OutputFormat, OverwriteDestination: req.OutputCanOverwrite, Writer: req.IdentityFileWriter, - JKSPassword: req.JKSPassword, + Password: req.Password, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/client/db/dbcmd/dbcmd.go b/lib/client/db/dbcmd/dbcmd.go index 40fc165c45c2e..97fee3c342f21 100644 --- a/lib/client/db/dbcmd/dbcmd.go +++ b/lib/client/db/dbcmd/dbcmd.go @@ -69,6 +69,8 @@ const ( elasticsearchSQLBin = "elasticsearch-sql-cli" // awsBin is the aws CLI program name. awsBin = "aws" + // oracleBin is the Oracle CLI program name. + oracleBin = "sql" ) // Execer is an abstraction of Go's exec module, as this one doesn't specify any interfaces. @@ -192,6 +194,9 @@ func (c *CLICommandBuilder) GetConnectCommand() (*exec.Cmd, error) { case defaults.ProtocolDynamoDB: return c.getDynamoDBCommand() + + case defaults.ProtocolOracle: + return c.getOracleCommand() } return nil, trace.BadParameter("unsupported database protocol: %v", c.db) @@ -619,6 +624,36 @@ func (c *CLICommandBuilder) getDynamoDBCommand() (*exec.Cmd, error) { return c.options.exe.Command(awsBin, args...), nil } +type jdbcOracleThinConnection struct { + host string + port int + db string + tnsAdmin string +} + +func (j *jdbcOracleThinConnection) ConnString() string { + return fmt.Sprintf(`jdbc:oracle:thin:@tcps://%s:%d/%s?TNS_ADMIN=%s`, j.host, j.port, j.db, j.tnsAdmin) +} + +func (c *CLICommandBuilder) getOracleCommand() (*exec.Cmd, error) { + cs := jdbcOracleThinConnection{ + host: c.host, + port: c.port, + db: c.db.Database, + tnsAdmin: c.profile.OracleWalletDir(c.profile.Cluster, c.db.ServiceName), + } + // Quote the address for printing as the address contains "?". + connString := cs.ConnString() + if c.options.printFormat { + connString = fmt.Sprintf(`'%s'`, connString) + } + args := []string{ + "-L", // dont retry + connString, + } + return c.options.exe.Command(oracleBin, args...), nil +} + func (c *CLICommandBuilder) getElasticsearchAlternativeCommands() []CommandAlternative { var commands []CommandAlternative if c.isElasticsearchSQLBinAvailable() { diff --git a/lib/client/db/dbcmd/dbcmd_test.go b/lib/client/db/dbcmd/dbcmd_test.go index 322a825a114df..c56de1c36bb2b 100644 --- a/lib/client/db/dbcmd/dbcmd_test.go +++ b/lib/client/db/dbcmd/dbcmd_test.go @@ -585,6 +585,24 @@ func TestCLICommandBuilderGetConnectCommand(t *testing.T) { cmd: []string{"aws", "--endpoint", "http://localhost:12345/", "[dynamodb|dynamodbstreams|dax]", ""}, wantErr: false, }, + { + name: "oracle", + dbProtocol: defaults.ProtocolOracle, + opts: []ConnectCommandFunc{WithLocalProxy("localhost", 12345, "")}, + execer: &fakeExec{}, + databaseName: "oracle01", + cmd: []string{"sql", "-L", "jdbc:oracle:thin:@tcps://localhost:12345/oracle01?TNS_ADMIN=/tmp/keys/example.com/bob-db/mysql-wallet"}, + wantErr: false, + }, + { + name: "Oracle with print format", + dbProtocol: defaults.ProtocolOracle, + opts: []ConnectCommandFunc{WithLocalProxy("localhost", 12345, ""), WithPrintFormat()}, + execer: &fakeExec{}, + databaseName: "oracle01", + cmd: []string{"sql", "-L", "'jdbc:oracle:thin:@tcps://localhost:12345/oracle01?TNS_ADMIN=/tmp/keys/example.com/bob-db/mysql-wallet'"}, + wantErr: false, + }, } for _, tt := range tests { diff --git a/lib/client/db/oracle/config.go b/lib/client/db/oracle/config.go new file mode 100644 index 0000000000000..a4c79be61630e --- /dev/null +++ b/lib/client/db/oracle/config.go @@ -0,0 +1,115 @@ +/* +Copyright 2023 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 oracle + +import ( + "bytes" + "os" + "path/filepath" + "text/template" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" +) + +type jdbcSettings struct { + KeyStoreFile string + TrustStoreFile string + KeyStorePassword string + TrustStorePassword string +} + +const jdbcPropertiesTemplateContent = ` +javax.net.ssl.keyStore={{.KeyStoreFile}} +javax.net.ssl.trustStore={{.TrustStoreFile}} +javax.net.ssl.keyStorePassword={{.KeyStorePassword}} +javax.net.ssl.trustStorePassword={{.TrustStorePassword}} +javax.net.ssl.keyStoreType=jks +javax.net.ssl.trustStoreType=jks +oracle.net.authentication_services=TCPS +` + +type tnsNamesORASettings struct { + ServiceName string + Host string + Port string +} + +const sqlnetORATemplateContent = ` +SSL_CLIENT_AUTHENTICATION = TRUE +SQLNET.AUTHENTICATION_SERVICES = (TCPS) + +WALLET_LOCATION = + (SOURCE = + (METHOD = FILE) + (METHOD_DATA = + (DIRECTORY = {{.WalletDir}}) + ) + ) +` + +type sqlnetORASettings struct { + WalletDir string +} + +const tnsnamesORATemplateContent = ` +{{.ServiceName}} = + (DESCRIPTION = + (ADDRESS_LIST = + (ADDRESS = (PROTOCOL = TCPS)(HOST = {{.Host}})(PORT = {{.Port}})) + ) + (CONNECT_DATA = + (SERVER = DEDICATED) + (SERVICE_NAME = {{.ServiceName}}) + ) + (SECURITY = + (SSL_SERVER_CERT_DN = "CN=localhost") + ) + ) +` + +var ( + jdbcPropertiesTemplate = template.Must(template.New("").Parse(jdbcPropertiesTemplateContent)) + sqlnetORATemplate = template.Must(template.New("").Parse(sqlnetORATemplateContent)) + tnsnamesORATemplate = template.Must(template.New("").Parse(tnsnamesORATemplateContent)) +) + +func (c jdbcSettings) template() *template.Template { return jdbcPropertiesTemplate } +func (c sqlnetORASettings) template() *template.Template { return sqlnetORATemplate } +func (c tnsNamesORASettings) template() *template.Template { return tnsnamesORATemplate } + +func (c jdbcSettings) configFilename() string { return "ojdbc.properties" } +func (c sqlnetORASettings) configFilename() string { return "sqlnet.ora" } +func (c tnsNamesORASettings) configFilename() string { return "tnsnames.ora" } + +type templateSettings interface { + template() *template.Template + configFilename() string +} + +func writeSettings(settings templateSettings, dir string) error { + var buff bytes.Buffer + if err := settings.template().Execute(&buff, settings); err != nil { + return trace.Wrap(err) + } + filePath := filepath.Join(dir, settings.configFilename()) + if err := os.WriteFile(filePath, buff.Bytes(), teleport.FileMaskOwnerOnly); err != nil { + return trace.Wrap(err) + } + return nil +} diff --git a/lib/client/db/oracle/oracle.go b/lib/client/db/oracle/oracle.go new file mode 100644 index 0000000000000..b4ee6b108727f --- /dev/null +++ b/lib/client/db/oracle/oracle.go @@ -0,0 +1,144 @@ +/* +Copyright 2023 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 oracle + +import ( + "bytes" + "crypto/x509" + "os" + "path/filepath" + "time" + + "github.com/gravitational/trace" + "github.com/pavlo-v-chernykh/keystore-go/v4" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/tlsca" + "github.com/gravitational/teleport/lib/utils" +) + +// GenerateClientConfiguration function generates following Oracle Client configuration: +// wallet.jks - Java Wallet format used by JDBC Drivers. +// sqlnet.ora - Generic Oracle Client Configuration File allowing to specify Wallet Location. +// tnsnames.ora - Oracle Net Service mapped to connections descriptors. +func GenerateClientConfiguration(key *client.Key, db tlsca.RouteToDatabase, profile *client.ProfileStatus) error { + walletPath := profile.OracleWalletDir(key.ClusterName, db.ServiceName) + if err := os.MkdirAll(walletPath, teleport.PrivateDirMode); err != nil { + return trace.Wrap(err) + } + password, err := utils.CryptoRandomHex(32) + if err != nil { + return trace.Wrap(err) + } + + localProxyCAPem, err := os.ReadFile(profile.DatabaseLocalCAPath()) + if err != nil { + return trace.ConvertSystemError(err) + } + + jksWalletPath, err := createClientWallet(key, localProxyCAPem, password, walletPath) + if err != nil { + return trace.Wrap(err) + } + + err = writeClientConfig(walletPath, jksWalletPath, password) + if err != nil { + return trace.Wrap(err) + } + return nil +} + +func createClientWallet(key *client.Key, certPem []byte, password string, walletPath string) (string, error) { + buff, err := createJKSWallet(key.PrivateKeyPEM(), certPem, certPem, password) + if err != nil { + return "", trace.Wrap(err) + } + walletFile := filepath.Join(walletPath, "wallet.jks") + if err := os.WriteFile(walletFile, buff, teleport.FileMaskOwnerOnly); err != nil { + return "", trace.Wrap(err) + } + return walletFile, nil +} + +func createJKSWallet(keyPEM, certPEM, caPEM []byte, password string) ([]byte, error) { + key, err := utils.ParsePrivateKey(keyPEM) + if err != nil { + return nil, trace.Wrap(err) + } + privateKey, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, trace.Wrap(err) + } + ks := keystore.New() + pkeIn := keystore.PrivateKeyEntry{ + CreationTime: time.Now(), + PrivateKey: privateKey, + CertificateChain: []keystore.Certificate{ + { + Type: "x509", + Content: certPEM, + }, + }, + } + + if err := ks.SetPrivateKeyEntry("teleportUserCert", pkeIn, []byte(password)); err != nil { + return nil, trace.Wrap(err) + } + trustIn := keystore.TrustedCertificateEntry{ + CreationTime: time.Now(), + Certificate: keystore.Certificate{ + Type: "x509", + Content: caPEM, + }, + } + if err := ks.SetTrustedCertificateEntry("teleportLocalCA", trustIn); err != nil { + return nil, trace.Wrap(err) + } + var buff bytes.Buffer + if err := ks.Store(&buff, []byte(password)); err != nil { + return nil, trace.Wrap(err) + } + return buff.Bytes(), nil +} + +func writeClientConfig(path string, jksFile string, password string) error { + var clientConfiguration = []templateSettings{ + tnsNamesORASettings{ + Host: "localhost", + // User default values that will be overwritten by JDBC connection string. + ServiceName: "XE", + Port: "2484", + }, + sqlnetORASettings{ + WalletDir: path, + }, + jdbcSettings{ + KeyStoreFile: jksFile, + TrustStoreFile: jksFile, + KeyStorePassword: password, + TrustStorePassword: password, + }, + } + + for _, v := range clientConfiguration { + if err := writeSettings(v, path); err != nil { + return trace.Wrap(err, "Failed to write %v", v.configFilename()) + } + } + return nil +} diff --git a/lib/client/identityfile/identity.go b/lib/client/identityfile/identity.go index 124b3345d547d..246e8ac283f97 100644 --- a/lib/client/identityfile/identity.go +++ b/lib/client/identityfile/identity.go @@ -20,17 +20,20 @@ package identityfile import ( "bytes" "context" + "crypto/rand" "crypto/x509" "encoding/pem" "fmt" "io/fs" "os" + "os/exec" "path/filepath" "strings" "time" "github.com/gravitational/trace" "github.com/pavlo-v-chernykh/keystore-go/v4" + "software.sslmate.com/src/go-pkcs12" "github.com/gravitational/teleport/api/identityfile" "github.com/gravitational/teleport/api/profile" @@ -99,6 +102,12 @@ const ( // DefaultFormat is what Teleport uses by default DefaultFormat = FormatFile + + // FormatOracle produces CA and ke pair in the Oracle wallet format. + // The execution depend on Orapki binary and if this binary is not found + // Teleport will print intermediate steps how to convert Teleport certs + // to Oracle wallet on Oracle Server instance. + FormatOracle Format = "oracle" ) // FormatList is a list of all possible FormatList. @@ -108,6 +117,7 @@ type FormatList []Format var KnownFileFormats = FormatList{ FormatFile, FormatOpenSSH, FormatTLS, FormatKubernetes, FormatDatabase, FormatWindows, FormatMongo, FormatCockroach, FormatRedis, FormatSnowflake, FormatElasticsearch, FormatCassandra, FormatScylla, + FormatOracle, } // String returns human-readable version of FormatList, ex: @@ -181,8 +191,8 @@ type WriteConfig struct { OverwriteDestination bool // Writer is the filesystem implementation. Writer ConfigWriter - // JKSPassword is the password for the JKS keystore used by Cassandra format. - JKSPassword string + // Password is the password for the JKS keystore used by Cassandra format and Oracle wallet. + Password string } // Write writes user credentials to disk in a specified format. @@ -309,7 +319,6 @@ func Write(ctx context.Context, cfg WriteConfig) (filesWritten []string, err err if err != nil { return nil, trace.Wrap(err) } - // FormatMongo is the same as FormatTLS or FormatDatabase certificate and // key are concatenated in the same .crt file which is what Mongo expects. case FormatMongo: @@ -371,6 +380,12 @@ func Write(ctx context.Context, cfg WriteConfig) (filesWritten []string, err err return nil, trace.Wrap(err) } filesWritten = append(filesWritten, out...) + case FormatOracle: + out, err := writeOracleFormat(cfg, writer) + if err != nil { + return nil, trace.Wrap(err) + } + filesWritten = append(filesWritten, out...) case FormatKubernetes: filesWritten = append(filesWritten, cfg.OutputPath) @@ -410,12 +425,12 @@ func Write(ctx context.Context, cfg WriteConfig) (filesWritten []string, err err } func writeCassandraFormat(cfg WriteConfig, writer ConfigWriter) ([]string, error) { - if cfg.JKSPassword == "" { + if cfg.Password == "" { pass, err := utils.CryptoRandomHex(16) if err != nil { return nil, trace.Wrap(err) } - cfg.JKSPassword = pass + cfg.Password = pass } // Cassandra expects a JKS keystore file with the private key and certificate // in it. The keystore file is password protected. @@ -445,6 +460,116 @@ func writeCassandraFormat(cfg WriteConfig, writer ConfigWriter) ([]string, error return []string{certPath, casPath}, nil } +// writeOracleFormat creates an Oracle wallet files if orapki Oracle tool is available +// is user env otherwise creates a p12 key-pair file allowing to run orapki on the Oracle server +// and create the Oracle wallet manually. +func writeOracleFormat(cfg WriteConfig, writer ConfigWriter) ([]string, error) { + certBlock, err := tlsca.ParseCertificatePEM(cfg.Key.TLSCert) + if err != nil { + return nil, trace.Wrap(err) + } + keyK, err := utils.ParsePrivateKeyPEM(cfg.Key.PrivateKeyPEM()) + if err != nil { + return nil, trace.Wrap(err) + } + var caCerts []*x509.Certificate + for _, ca := range cfg.Key.TrustedCerts { + for _, cert := range ca.TLSCertificates { + c, err := tlsca.ParseCertificatePEM(cert) + if err != nil { + return nil, trace.Wrap(err) + } + caCerts = append(caCerts, c) + } + } + + pf, err := pkcs12.Encode(rand.Reader, keyK, certBlock, caCerts, cfg.Password) + if err != nil { + return nil, trace.Wrap(err) + } + + p12Path := cfg.OutputPath + ".p12" + certPath := cfg.OutputPath + ".crt" + + if err := writer.WriteFile(p12Path, pf, identityfile.FilePermissions); err != nil { + return nil, trace.Wrap(err) + } + err = writer.WriteFile(certPath, cfg.Key.TLSCert, identityfile.FilePermissions) + if err != nil { + return nil, trace.Wrap(err) + } + + // Is ORAPKI binary is available is user env run command ang generate autologin Oracle wallet. + if isOrapkiAvailable() { + // Is Orapki is available in the user env create the Oracle wallet directly. + // otherwise Orapki tool needs to be executed on the server site to import keypair to + // Oracle wallet. + if err := createOracleWallet(cfg.OutputPath, p12Path, certPath, cfg.Password); err != nil { + return nil, trace.Wrap(err) + } + // If Oracle Wallet was created the raw p12 keypair and trusted cert are no longer needed. + if err := os.Remove(p12Path); err != nil { + return nil, trace.Wrap(err) + } + if err := os.Remove(certPath); err != nil { + return nil, trace.Wrap(err) + } + // Return the path to the Oracle wallet. + return []string{cfg.OutputPath}, nil + } + + // Otherwise return destinations to p12 keypair and trusted CA allowing a user to run the convert flow on the + // Oracle server instance in order to create Oracle wallet file. + return []string{p12Path, certPath}, nil +} + +const ( + orapkiBinary = "orapki" +) + +func isOrapkiAvailable() bool { + _, err := exec.LookPath(orapkiBinary) + return err == nil +} + +func createOracleWallet(walletPath, p12Path, certPath, password string) error { + errDetailsFormat := "\n\nOrapki command:\n%s \n\nCompleted with following error: \n%s" + // Create Raw Oracle wallet with auto_login_only flag - no password required. + args := []string{ + "wallet", "create", "-wallet", walletPath, + "-auto_login_only", + } + cmd := exec.Command(orapkiBinary, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return trace.Wrap(err, fmt.Sprintf(errDetailsFormat, cmd.String(), output)) + } + + // Import keypair into oracle wallet as a user cert. + args = []string{ + "wallet", "import_pkcs12", "-wallet", walletPath, + "-auto_login_only", + "-pkcs12file", p12Path, + "-pkcs12pwd", password, + } + cmd = exec.Command(orapkiBinary, args...) + if output, err := exec.Command(orapkiBinary, args...).CombinedOutput(); err != nil { + return trace.Wrap(err, fmt.Sprintf(errDetailsFormat, cmd.String(), output)) + } + + // Add import teleport CA to the oracle wallet. + args = []string{ + "wallet", "add", "-wallet", walletPath, + "-trusted_cert", + "-auto_login_only", + "-cert", certPath, + } + cmd = exec.Command(orapkiBinary, args...) + if output, err := exec.Command(orapkiBinary, args...).CombinedOutput(); err != nil { + return trace.Wrap(err, fmt.Sprintf(errDetailsFormat, cmd.String(), output)) + } + return nil +} + func prepareCassandraTruststore(cfg WriteConfig) (*bytes.Buffer, error) { var caCerts []byte for _, ca := range cfg.Key.TrustedCerts { @@ -466,7 +591,7 @@ func prepareCassandraTruststore(cfg WriteConfig) (*bytes.Buffer, error) { return nil, trace.Wrap(err) } var buff bytes.Buffer - if err := ks.Store(&buff, []byte(cfg.JKSPassword)); err != nil { + if err := ks.Store(&buff, []byte(cfg.Password)); err != nil { return nil, trace.Wrap(err) } return &buff, nil @@ -497,11 +622,11 @@ func prepareCassandraKeystore(cfg WriteConfig) (*bytes.Buffer, error) { }, }, } - if err := ks.SetPrivateKeyEntry("cassandra", pkeIn, []byte(cfg.JKSPassword)); err != nil { + if err := ks.SetPrivateKeyEntry("cassandra", pkeIn, []byte(cfg.Password)); err != nil { return nil, trace.Wrap(err) } var buff bytes.Buffer - if err := ks.Store(&buff, []byte(cfg.JKSPassword)); err != nil { + if err := ks.Store(&buff, []byte(cfg.Password)); err != nil { return nil, trace.Wrap(err) } return &buff, nil diff --git a/lib/client/interfaces.go b/lib/client/interfaces.go index 57631d2f0de35..f662686c22a22 100644 --- a/lib/client/interfaces.go +++ b/lib/client/interfaces.go @@ -367,7 +367,7 @@ func isTeleportAgentKey(key *agent.Key) bool { return strings.HasPrefix(key.Comment, agentKeyCommentPrefix+agentKeyCommentSeparator) } -// AsAgentKeys converts client.Key struct to an agent.AddedKey. Any agent.AddedKey +// AsAgentKey converts client.Key struct to an agent.AddedKey. Any agent.AddedKey // can be added to a local agent (keyring), nut non-standard keys cannot be added // to an SSH system agent through the ssh agent protocol. Check canAddToSystemAgent // before adding this key to an SSH system agent. diff --git a/lib/client/profile.go b/lib/client/profile.go index 592a44c50f7da..989e078dae655 100644 --- a/lib/client/profile.go +++ b/lib/client/profile.go @@ -449,6 +449,36 @@ func (p *ProfileStatus) DatabaseCertPathForCluster(clusterName string, databaseN return keypaths.DatabaseCertPath(p.Dir, p.Name, p.Username, clusterName, databaseName) } +// OracleWalletDir returns path to the specified database access +// certificate for this profile, for the specified cluster. +// +// It's kept in /keys//-db//dbname-wallet/ +// +// If the input cluster name is an empty string, the selected cluster in the +// profile will be used. +func (p *ProfileStatus) OracleWalletDir(clusterName string, databaseName string) string { + if clusterName == "" { + clusterName = p.Cluster + } + + if path, ok := p.virtualPathFromEnv(VirtualPathDatabase, VirtualPathDatabaseParams(databaseName)); ok { + return path + } + + return keypaths.DatabaseOracleWalletDirectory(p.Dir, p.Name, p.Username, clusterName, databaseName) +} + +// DatabaseLocalCAPath returns the specified db 's self-signed localhost CA path for +// this profile. +// +// It's kept in /keys//-db/proxy-localca.pem +func (p *ProfileStatus) DatabaseLocalCAPath() string { + if path, ok := p.virtualPathFromEnv(VirtualPathDatabase, nil); ok { + return path + } + return filepath.Join(keypaths.DatabaseDir(p.Dir, p.Name, p.Username), "proxy-localca.pem") +} + // AppCertPath returns path to the specified app access certificate // for this profile. // diff --git a/lib/defaults/defaults.go b/lib/defaults/defaults.go index 4eeca393d7ed0..17c5ab1fdc8c7 100644 --- a/lib/defaults/defaults.go +++ b/lib/defaults/defaults.go @@ -417,6 +417,8 @@ const ( ProtocolMySQL = "mysql" // ProtocolMongoDB is the MongoDB database protocol. ProtocolMongoDB = "mongodb" + // ProtocolOracle is the Oracle database protocol. + ProtocolOracle = "oracle" // ProtocolRedis is the Redis database protocol. ProtocolRedis = "redis" // ProtocolCockroachDB is the CockroachDB database protocol. @@ -442,6 +444,7 @@ var DatabaseProtocols = []string{ ProtocolPostgres, ProtocolMySQL, ProtocolMongoDB, + ProtocolOracle, ProtocolCockroachDB, ProtocolRedis, ProtocolSnowflake, @@ -461,6 +464,8 @@ func ReadableDatabaseProtocol(p string) string { return "MySQL" case ProtocolMongoDB: return "MongoDB" + case ProtocolOracle: + return "Oracle" case ProtocolCockroachDB: return "CockroachDB" case ProtocolRedis: diff --git a/lib/service/service.go b/lib/service/service.go index 219f094bcc041..79114b58d8715 100644 --- a/lib/service/service.go +++ b/lib/service/service.go @@ -3150,6 +3150,7 @@ func (process *TeleportProcess) setupProxyListeners(networkingConfig types.Clust } listeners.db.postgres = listener } + } tunnelStrategy, err := networkingConfig.GetTunnelStrategyType() @@ -3988,6 +3989,7 @@ func (process *TeleportProcess) initProxyEndpoint(conn *Connector) error { // route extracted connection to ALPN Proxy DB TLS Handler. MatchFunc: alpnproxy.MatchByProtocol( alpncommon.ProtocolMongoDB, + alpncommon.ProtocolOracle, alpncommon.ProtocolRedisDB, alpncommon.ProtocolSnowflake, alpncommon.ProtocolSQLServer, diff --git a/lib/service/service_test.go b/lib/service/service_test.go index 6d0054df87a0a..abbd69f30ea28 100644 --- a/lib/service/service_test.go +++ b/lib/service/service_test.go @@ -488,6 +488,7 @@ func TestSetupProxyTLSConfig(t *testing.T) { "teleport-postgres-ping", "teleport-mysql-ping", "teleport-mongodb-ping", + "teleport-oracle-ping", "teleport-redis-ping", "teleport-sqlserver-ping", "teleport-snowflake-ping", @@ -504,6 +505,7 @@ func TestSetupProxyTLSConfig(t *testing.T) { "teleport-postgres", "teleport-mysql", "teleport-mongodb", + "teleport-oracle", "teleport-redis", "teleport-sqlserver", "teleport-snowflake", @@ -520,6 +522,7 @@ func TestSetupProxyTLSConfig(t *testing.T) { "teleport-postgres-ping", "teleport-mysql-ping", "teleport-mongodb-ping", + "teleport-oracle-ping", "teleport-redis-ping", "teleport-sqlserver-ping", "teleport-snowflake-ping", @@ -539,6 +542,7 @@ func TestSetupProxyTLSConfig(t *testing.T) { "teleport-postgres", "teleport-mysql", "teleport-mongodb", + "teleport-oracle", "teleport-redis", "teleport-sqlserver", "teleport-snowflake", diff --git a/lib/services/database.go b/lib/services/database.go index 2f9e595efbaba..9b8149d6e102e 100644 --- a/lib/services/database.go +++ b/lib/services/database.go @@ -50,6 +50,7 @@ import ( libcloudaws "github.com/gravitational/teleport/lib/cloud/aws" "github.com/gravitational/teleport/lib/cloud/azure" "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/srv/db/common/enterprise" "github.com/gravitational/teleport/lib/srv/db/redis/connection" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" @@ -136,6 +137,9 @@ func UnmarshalDatabase(data []byte, opts ...MarshalOption) (types.Database, erro // ValidateDatabase validates a types.Database. func ValidateDatabase(db types.Database) error { + if err := enterprise.ProtocolValidation(db.GetProtocol()); err != nil { + return trace.Wrap(err) + } if err := db.CheckAndSetDefaults(); err != nil { return trace.Wrap(err) } diff --git a/lib/srv/alpnproxy/common/protocols.go b/lib/srv/alpnproxy/common/protocols.go index cc8e46ab41141..bcd64a30e0a19 100644 --- a/lib/srv/alpnproxy/common/protocols.go +++ b/lib/srv/alpnproxy/common/protocols.go @@ -39,6 +39,9 @@ const ( // ProtocolMongoDB is TLS ALPN protocol value used to indicate Mongo protocol. ProtocolMongoDB Protocol = "teleport-mongodb" + // ProtocolOracle is TLS ALPN protocol value used to indicate Oracle protocol. + ProtocolOracle Protocol = "teleport-oracle" + // ProtocolRedisDB is TLS ALPN protocol value used to indicate Redis protocol. ProtocolRedisDB Protocol = "teleport-redis" @@ -145,6 +148,8 @@ func ToALPNProtocol(dbProtocol string) (Protocol, error) { return ProtocolPostgres, nil case defaults.ProtocolMongoDB: return ProtocolMongoDB, nil + case defaults.ProtocolOracle: + return ProtocolOracle, nil case defaults.ProtocolRedis: return ProtocolRedisDB, nil case defaults.ProtocolSQLServer: @@ -170,6 +175,7 @@ func ToALPNProtocol(dbProtocol string) (Protocol, error) { func IsDBTLSProtocol(protocol Protocol) bool { dbTLSProtocols := []Protocol{ ProtocolMongoDB, + ProtocolOracle, ProtocolRedisDB, ProtocolSQLServer, ProtocolSnowflake, @@ -188,6 +194,7 @@ var DatabaseProtocols = []Protocol{ ProtocolPostgres, ProtocolMySQL, ProtocolMongoDB, + ProtocolOracle, ProtocolRedisDB, ProtocolSQLServer, ProtocolSnowflake, diff --git a/lib/srv/db/common/engines.go b/lib/srv/db/common/engines.go index f363c00fc1bdd..8066e567eb490 100644 --- a/lib/srv/db/common/engines.go +++ b/lib/srv/db/common/engines.go @@ -26,6 +26,7 @@ import ( "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/cloud" + "github.com/gravitational/teleport/lib/srv/db/common/enterprise" ) var ( @@ -69,6 +70,10 @@ func CheckEngines(names ...string) error { enginesMu.RLock() defer enginesMu.RUnlock() for _, name := range names { + if err := enterprise.ProtocolValidation(name); err != nil { + // Don't assert Enterprise protocol is a build is OSS + continue + } if engines[name] == nil { return trace.NotFound("database engine %q is not registered", name) } diff --git a/lib/srv/db/common/enterprise/enterprise.go b/lib/srv/db/common/enterprise/enterprise.go new file mode 100644 index 0000000000000..264847764e25c --- /dev/null +++ b/lib/srv/db/common/enterprise/enterprise.go @@ -0,0 +1,35 @@ +/* +Copyright 2023 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 enterprise + +import ( + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/modules" +) + +// ProtocolValidation checks if protocol is supported for current build. +func ProtocolValidation(dbProtocol string) error { + switch dbProtocol { + case defaults.ProtocolOracle: + if modules.GetModules().BuildType() != modules.BuildEnterprise { + return trace.BadParameter("%s database protocol is only available with an enterprise license", dbProtocol) + } + } + return nil +} diff --git a/lib/srv/db/proxyserver.go b/lib/srv/db/proxyserver.go index d745c317e1253..11a6a4232e0cb 100644 --- a/lib/srv/db/proxyserver.go +++ b/lib/srv/db/proxyserver.go @@ -44,6 +44,7 @@ import ( "github.com/gravitational/teleport/lib/limiter" "github.com/gravitational/teleport/lib/reversetunnel" "github.com/gravitational/teleport/lib/srv/db/common" + "github.com/gravitational/teleport/lib/srv/db/common/enterprise" "github.com/gravitational/teleport/lib/srv/db/dbutils" "github.com/gravitational/teleport/lib/srv/db/mysql" "github.com/gravitational/teleport/lib/srv/db/postgres" @@ -327,6 +328,9 @@ func (s *ProxyServer) handleConnection(conn net.Conn) error { s.cfg.IngressReporter.ConnectionAuthenticated(ingress.DatabaseTLS, conn) defer s.cfg.IngressReporter.AuthenticatedConnectionClosed(ingress.DatabaseTLS, conn) } + if enterprise.ProtocolValidation(proxyCtx.Identity.RouteToDatabase.Protocol); err != nil { + return trace.Wrap(err) + } switch proxyCtx.Identity.RouteToDatabase.Protocol { case defaults.ProtocolPostgres, defaults.ProtocolCockroachDB: diff --git a/lib/utils/fs.go b/lib/utils/fs.go index a23217939a8ee..a8c48abdb66d5 100644 --- a/lib/utils/fs.go +++ b/lib/utils/fs.go @@ -254,3 +254,13 @@ func overwriteFile(filePath string) (err error) { _, err = io.CopyN(f, rand.Reader, size) return trace.Wrap(err) } + +// RemoveFileIfExist removes file if exits. +func RemoveFileIfExist(filePath string) { + if !FileExists(filePath) { + return + } + if err := os.Remove(filePath); err != nil { + log.WithError(err).Warnf("Failed to remove %v", filePath) + } +} diff --git a/tool/tctl/common/auth_command.go b/tool/tctl/common/auth_command.go index 6ab55967af136..76bb1a0f33319 100644 --- a/tool/tctl/common/auth_command.go +++ b/tool/tctl/common/auth_command.go @@ -21,6 +21,7 @@ import ( "net" "net/url" "os" + "path/filepath" "strconv" "strings" "text/template" @@ -74,7 +75,7 @@ type AuthCommand struct { windowsDomain string windowsSID string signOverwrite bool - jksPassword string + password string caType string rotateGracePeriod time.Duration @@ -256,12 +257,20 @@ func (a *AuthCommand) GenerateAndSignKeys(ctx context.Context, clusterAPI auth.C if err != nil { return trace.Wrap(err) } - a.jksPassword = jskPass + a.password = jskPass return a.generateDatabaseKeys(ctx, clusterAPI) case identityfile.FormatSnowflake: return a.generateSnowflakeKey(ctx, clusterAPI) case identityfile.FormatWindows: return a.generateWindowsCert(ctx, clusterAPI) + case identityfile.FormatOracle: + oracleWalletPass, err := utils.CryptoRandomHex(32) + if err != nil { + return trace.Wrap(err) + } + a.password = oracleWalletPass + return a.generateDBOracleCert(ctx, clusterAPI) + } switch { case a.genUser != "" && a.genHost == "": @@ -504,14 +513,14 @@ func (a *AuthCommand) generateDatabaseKeysForKey(ctx context.Context, clusterAPI OutputLocation: a.output, TTL: a.genTTL, Key: key, - JKSPassword: a.jksPassword, + Password: a.password, } filesWritten, err := db.GenerateDatabaseCertificates(ctx, dbCertReq) if err != nil { return trace.Wrap(err) } - return trace.Wrap(writeHelperMessageDBmTLS(os.Stdout, filesWritten, a.output, a.outputFormat, a.jksPassword)) + return trace.Wrap(writeHelperMessageDBmTLS(os.Stdout, filesWritten, a.output, a.outputFormat, a.password)) } var mapIdentityFileFormatHelperTemplate = map[identityfile.Format]*template.Template{ @@ -523,9 +532,10 @@ var mapIdentityFileFormatHelperTemplate = map[identityfile.Format]*template.Temp identityfile.FormatElasticsearch: elasticsearchAuthSignTpl, identityfile.FormatCassandra: cassandraAuthSignTpl, identityfile.FormatScylla: scyllaAuthSignTpl, + identityfile.FormatOracle: oracleAuthSignTpl, } -func writeHelperMessageDBmTLS(writer io.Writer, filesWritten []string, output string, outputFormat identityfile.Format, jksPassword string) error { +func writeHelperMessageDBmTLS(writer io.Writer, filesWritten []string, output string, outputFormat identityfile.Format, password string) error { if writer == nil { return nil } @@ -537,9 +547,13 @@ func writeHelperMessageDBmTLS(writer io.Writer, filesWritten []string, output st return nil } tplVars := map[string]interface{}{ - "files": strings.Join(filesWritten, ", "), - "jksPassword": jksPassword, - "output": output, + "files": strings.Join(filesWritten, ", "), + "password": password, + "output": output, + } + if outputFormat == defaults.ProtocolOracle { + tplVars["manualOrapkiFlow"] = len(filesWritten) != 1 + tplVars["walletDir"] = filepath.Dir(output) } return trace.Wrap(tpl.Execute(writer, tplVars)) @@ -624,25 +638,50 @@ https://www.elastic.co/guide/en/elasticsearch/reference/current/security-setting `)) cassandraAuthSignTpl = template.Must(template.New("").Parse(`Database credentials have been written to {{.files}}. - To enable mutual TLS on your Cassandra server, add the following to your cassandra.yaml configuration file: - client_encryption_options: enabled: true optional: false keystore: /path/to/{{.output}}.keystore - keystore_password: "{{.jksPassword}}" - + keystore_password: "{{.password}}" require_client_auth: true truststore: /path/to/{{.output}}.truststore - truststore_password: "{{.jksPassword}}" + truststore_password: "{{.password}}" protocol: TLS algorithm: SunX509 store_type: JKS cipher_suites: [TLS_RSA_WITH_AES_256_CBC_SHA] `)) + oracleAuthSignTpl = template.Must(template.New("").Parse(` +{{if .manualOrapkiFlow}} +Orapki binary was not found. Please create oracle wallet file manually by running the following commands on the Oracle server: + +orapki wallet create -wallet {{.walletDir}} -auto_login_only +orapki wallet import_pkcs12 -wallet {{.walletDir}} -auto_login_only -pkcs12file {{.output}}.p12 -pkcs12pwd {{.password}} +orapki wallet add -wallet {{.walletDir}} -trusted_cert -auto_login_only -cert {{.output}}.crt +{{end}} +To enable mutual TLS on your Oracle server, add the following settings to Oracle sqlnet.ora configuration file: + +WALLET_LOCATION = (SOURCE = (METHOD = FILE)(METHOD_DATA = (DIRECTORY = /path/to/oracleWalletDir))) +SSL_CLIENT_AUTHENTICATION = TRUE +SQLNET.AUTHENTICATION_SERVICES = (TCPS) + + +To enable mutual TLS on your Oracle server, add the following TCPS entries to listener.ora configuration file: + +LISTENER = + (DESCRIPTION_LIST = + (DESCRIPTION = + (ADDRESS = (PROTOCOL = TCPS)(HOST = 0.0.0.0)(PORT = 2484)) + ) + ) + +WALLET_LOCATION = (SOURCE = (METHOD = FILE)(METHOD_DATA = (DIRECTORY = /path/to/oracleWalletDir))) +SSL_CLIENT_AUTHENTICATION = TRUE +`)) + scyllaAuthSignTpl = template.Must(template.New("").Parse(`Database credentials have been written to {{.files}}. To enable mutual TLS on your Scylla server, add the following to your @@ -949,6 +988,14 @@ func (a *AuthCommand) checkProxyAddr(ctx context.Context, clusterAPI auth.Client return trace.BadParameter("couldn't find registered public proxies, specify --proxy when using --format=%q", identityfile.FormatKubernetes) } +func (a *AuthCommand) generateDBOracleCert(ctx context.Context, api auth.ClientI) error { + key, err := client.GenerateRSAKey() + if err != nil { + return trace.Wrap(err) + } + return a.generateDatabaseKeysForKey(ctx, api, key) +} + func parseURL(rawurl string) (*url.URL, error) { u, err := url.Parse(rawurl) if err != nil { diff --git a/tool/tsh/app.go b/tool/tsh/app.go index da7eeb0601127..2e010dff6e434 100644 --- a/tool/tsh/app.go +++ b/tool/tsh/app.go @@ -487,18 +487,7 @@ func pickActiveApp(cf *CLIConf) (*tlsca.RouteToApp, error) { // removeAppLocalFiles removes generated local files for the provided app. func removeAppLocalFiles(profile *client.ProfileStatus, appName string) { - removeFileIfExist(profile.AppLocalCAPath(appName)) -} - -// removeFileIfExist removes a local file if it exists. -func removeFileIfExist(filePath string) { - if !utils.FileExists(filePath) { - return - } - - if err := os.Remove(filePath); err != nil { - log.WithError(err).Warnf("Failed to remove %v", filePath) - } + utils.RemoveFileIfExist(profile.AppLocalCAPath(appName)) } // loadAppSelfSignedCA loads self-signed CA for provided app, or tries to diff --git a/tool/tsh/db.go b/tool/tsh/db.go index 55f5e88a5ae15..592d2b53bc9c8 100644 --- a/tool/tsh/db.go +++ b/tool/tsh/db.go @@ -42,6 +42,7 @@ import ( "github.com/gravitational/teleport/lib/client" dbprofile "github.com/gravitational/teleport/lib/client/db" "github.com/gravitational/teleport/lib/client/db/dbcmd" + "github.com/gravitational/teleport/lib/client/db/oracle" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/srv/alpnproxy" @@ -294,7 +295,7 @@ func checkAndSetDBRouteDefaults(r *tlsca.RouteToDatabase) error { // Elasticsearch needs database username too. if r.Username == "" { switch r.Protocol { - case defaults.ProtocolMongoDB, defaults.ProtocolElasticsearch: + case defaults.ProtocolMongoDB, defaults.ProtocolElasticsearch, defaults.ProtocolOracle: return trace.BadParameter("please provide the database user name using the --db-user flag") case defaults.ProtocolRedis: // Default to "default" in the same way as Redis does. We need the username to check access on our side. @@ -309,6 +310,12 @@ func checkAndSetDBRouteDefaults(r *tlsca.RouteToDatabase) error { r.ServiceName, defaults.ReadableDatabaseProtocol(r.Protocol), r.Database) r.Database = "" } + } else { + switch r.Protocol { + // Always require db-name for Oracle Protocol. + case defaults.ProtocolOracle: + return trace.BadParameter("please provide the database name using the --db-name flag") + } } return nil } @@ -324,12 +331,12 @@ func databaseLogin(cf *CLIConf, tc *client.TeleportClient, route tlsca.RouteToDa return trace.Wrap(err) } + var key *client.Key // Identity files themselves act as the database credentials (if any), so // don't bother fetching new certs. if profile.IsVirtual { log.Info("Note: already logged in due to an identity file (`-i ...`); will only update database config files.") } else { - var key *client.Key if err = client.RetryWithRelogin(cf.Context, tc, func() error { key, err = tc.IssueUserCertsWithMFA(cf.Context, client.ReissueParams{ RouteToCluster: tc.SiteName, @@ -350,6 +357,16 @@ func databaseLogin(cf *CLIConf, tc *client.TeleportClient, route tlsca.RouteToDa } } + if route.Protocol == defaults.ProtocolOracle { + if err := generateDBLocalProxyCert(key, profile); err != nil { + return trace.Wrap(err) + } + err = oracle.GenerateClientConfiguration(key, route, profile) + if err != nil { + return trace.Wrap(err) + } + } + // Refresh the profile. profile, err = tc.ProfileStatus() if err != nil { @@ -589,7 +606,7 @@ func maybeStartLocalProxy(ctx context.Context, cf *CLIConf, log.Debugf("Starting local proxy because: %v", strings.Join(requires.localProxyReasons, ", ")) } - listener, err := net.Listen("tcp", "localhost:0") + listener, err := createLocalProxyListener("localhost:0", route, profile) if err != nil { return nil, trace.Wrap(err) } @@ -653,6 +670,25 @@ type localProxyConfig struct { tunnel bool } +func createLocalProxyListener(addr string, route *tlsca.RouteToDatabase, profile *client.ProfileStatus) (net.Listener, error) { + if route.Protocol == defaults.ProtocolOracle { + localCert, err := tls.LoadX509KeyPair( + profile.DatabaseLocalCAPath(), + profile.KeyPath(), + ) + if err != nil { + return nil, trace.Wrap(err) + } + l, err := tls.Listen("tcp", addr, &tls.Config{ + Certificates: []tls.Certificate{localCert}, + ServerName: "localhost", + }) + return l, trace.Wrap(err) + } + l, err := net.Listen("tcp", addr) + return l, trace.Wrap(err) +} + // prepareLocalProxyOptions created localProxyOpts needed to create local proxy from localProxyConfig. func prepareLocalProxyOptions(arg *localProxyConfig) ([]alpnproxy.LocalProxyConfigOpt, error) { if err := checkAndSetDBRouteDefaults(&arg.route); err != nil { @@ -861,9 +897,17 @@ func getDatabase(cf *CLIConf, tc *client.TeleportClient, dbName string) (types.D func needDatabaseRelogin(cf *CLIConf, tc *client.TeleportClient, route *tlsca.RouteToDatabase, profile *client.ProfileStatus, requires *dbLocalProxyRequirement) (bool, error) { if (requires.localProxy && requires.tunnel) || isLocalProxyTunnelRequested(cf) { - // We don't need to login if using a local proxy tunnel, - // because a local proxy tunnel will handle db login itself. - return false, nil + switch route.Protocol { + case defaults.ProtocolOracle: + // Oracle Protocol needs to generate a local configuration files. + // thus even is tunnel mode was requested the login flow should check + // if the Oracle client files should be updated. + default: + // We don't need to login if using a local proxy tunnel, + // because a local proxy tunnel will handle db login itself. + return false, nil + + } } found := false activeDatabases, err := profile.DatabasesForCluster(tc.SiteName) @@ -1121,7 +1165,9 @@ func getDBLocalProxyRequirement(tc *client.TeleportClient, route *tlsca.RouteToD case defaults.ProtocolSnowflake, defaults.ProtocolDynamoDB, defaults.ProtocolSQLServer, - defaults.ProtocolCassandra: + defaults.ProtocolCassandra, + defaults.ProtocolOracle: + // Some protocols only work in the local tunnel mode. out.addLocalProxyWithTunnel(formatDBProtocolReason(route.Protocol)) case defaults.ProtocolMySQL: diff --git a/tool/tsh/db_test.go b/tool/tsh/db_test.go index c492441dd4317..95ea3febd66b8 100644 --- a/tool/tsh/db_test.go +++ b/tool/tsh/db_test.go @@ -660,6 +660,12 @@ func TestFormatDatabaseConnectArgs(t *testing.T) { route: tlsca.RouteToDatabase{Protocol: defaults.ProtocolDynamoDB, ServiceName: "svc"}, wantFlags: []string{"--db-user=", "svc"}, }, + { + name: "match user and db name, oracle protocol", + cluster: "", + route: tlsca.RouteToDatabase{Protocol: defaults.ProtocolOracle, ServiceName: "svc"}, + wantFlags: []string{"--db-user=", "--db-name=", "svc"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/tool/tsh/proxy.go b/tool/tsh/proxy.go index a74715198c285..7030e9a7eca0f 100644 --- a/tool/tsh/proxy.go +++ b/tool/tsh/proxy.go @@ -19,6 +19,7 @@ package main import ( "context" "crypto/tls" + "crypto/x509/pkix" "fmt" "io" "net" @@ -35,6 +36,7 @@ import ( "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" + "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/client" "github.com/gravitational/teleport/api/client/proxy" "github.com/gravitational/teleport/api/client/webclient" @@ -46,6 +48,7 @@ import ( "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/srv/alpnproxy" alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common" + "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" ) @@ -405,10 +408,12 @@ func onProxyCommandDB(cf *CLIConf) error { randomPort = false addr = fmt.Sprintf("127.0.0.1:%s", cf.LocalProxyPort) } - listener, err := net.Listen("tcp", addr) + + listener, err := createLocalProxyListener(addr, route, profile) if err != nil { return trace.Wrap(err) } + defer func() { if err := listener.Close(); err != nil { log.WithError(err).Warnf("Failed to close listener.") @@ -470,7 +475,7 @@ func onProxyCommandDB(cf *CLIConf) error { "randomPort": randomPort, } - tmpl := chooseProxyCommandTemplate(templateArgs, commands) + tmpl := chooseProxyCommandTemplate(templateArgs, commands, route.Protocol) err = tmpl.Execute(os.Stdout, templateArgs) if err != nil { return trace.Wrap(err) @@ -516,10 +521,14 @@ type templateCommandItem struct { Command string } -func chooseProxyCommandTemplate(templateArgs map[string]any, commands []dbcmd.CommandAlternative) *template.Template { +func chooseProxyCommandTemplate(templateArgs map[string]any, commands []dbcmd.CommandAlternative, protocol string) *template.Template { // there is only one command, use plain template. if len(commands) == 1 { templateArgs["command"] = formatCommand(commands[0].Command) + if protocol == defaults.ProtocolOracle { + templateArgs["args"] = commands[0].Command.Args + return dbProxyOracleAuthTpl + } return dbProxyAuthTpl } @@ -788,6 +797,32 @@ func isLocalProxyTunnelRequested(cf *CLIConf) bool { cf.LocalProxyKeyFile != "" } +func generateDBLocalProxyCert(key *libclient.Key, profile *libclient.ProfileStatus) error { + path := profile.DatabaseLocalCAPath() + if utils.FileExists(path) { + return nil + + } + certPem, err := tlsca.GenerateSelfSignedCAWithConfig(tlsca.GenerateCAConfig{ + Entity: pkix.Name{ + CommonName: "localhost", + Organization: []string{"Teleport"}, + }, + Signer: key, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP(defaults.Localhost)}, + TTL: defaults.CATTL, + }) + if err != nil { + return trace.Wrap(err) + } + + if err := os.WriteFile(profile.DatabaseLocalCAPath(), certPem, teleport.FileMaskOwnerOnly); err != nil { + return trace.ConvertSystemError(err) + } + return nil +} + // dbProxyTpl is the message that gets printed to a user when a database proxy is started. var dbProxyTpl = template.Must(template.New("").Parse(`Started DB proxy on {{.address}} {{if .randomPort}}To avoid port randomization, you can choose the listening port using the --port flag. @@ -798,6 +833,10 @@ Use following credentials to connect to the {{.database}} proxy: key_file={{.key}} `)) +var templateFunctions = map[string]any{ + "contains": strings.Contains, +} + // dbProxyAuthTpl is the message that's printed for an authenticated db proxy. var dbProxyAuthTpl = template.Must(template.New("").Parse( `Started authenticated tunnel for the {{.type}} database "{{.database}}" in cluster "{{.cluster}}" on {{.address}}. @@ -807,6 +846,22 @@ Use the following command to connect to the database or to the address above usi $ {{.command}} `)) +// dbProxyOracleAuthTpl is the message that's printed for an authenticated db proxy. +var dbProxyOracleAuthTpl = template.Must(template.New("").Funcs(templateFunctions).Parse( + `Started authenticated tunnel for the {{.type}} database "{{.database}}" in cluster "{{.cluster}}" on {{.address}}. +{{if .randomPort}}To avoid port randomization, you can choose the listening port using the --port flag. +{{end}} +Use the following command to connect to the Oracle database server using CLI: + $ {{.command}} + +or using following Oracle JDBC connection string in order to connect with other GUI/CLI clients: +{{- range $val := .args}} + {{- if contains $val "jdbc:oracle:"}} + {{$val}} + {{- end}} +{{- end}} +`)) + // dbProxyAuthMultiTpl is the message that's printed for an authenticated db proxy if there are multiple command options. var dbProxyAuthMultiTpl = template.Must(template.New("").Parse( `Started authenticated tunnel for the {{.type}} database "{{.database}}" in cluster "{{.cluster}}" on {{.address}}. diff --git a/tool/tsh/proxy_test.go b/tool/tsh/proxy_test.go index 20b77c1d56036..088e4792cc0f4 100644 --- a/tool/tsh/proxy_test.go +++ b/tool/tsh/proxy_test.go @@ -996,7 +996,7 @@ Use one of the following commands to connect to the database or to the address a for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { templateArgs := map[string]any{} - tpl := chooseProxyCommandTemplate(templateArgs, tt.commands) + tpl := chooseProxyCommandTemplate(templateArgs, tt.commands, "") require.Equal(t, tt.wantTemplate, tpl) require.Equal(t, tt.wantTemplateArgs, templateArgs)