Skip to content

Commit

Permalink
Support Oracle connections without wallet (#49753)
Browse files Browse the repository at this point in the history
* Add support for Oracle connections without wallet. Backwards compatible.

* Bypass maybeAddOracleOptions for non-Oracle protos

* use named const

* fix lint issues

* Update PR to slog

* lint fix
  • Loading branch information
Tener authored Jan 3, 2025
1 parent 35a0440 commit 7b0de41
Show file tree
Hide file tree
Showing 5 changed files with 409 additions and 52 deletions.
85 changes: 64 additions & 21 deletions lib/client/db/dbcmd/dbcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ const (
openSearchSQLBin = "opensearchsql"
// awsBin is the aws CLI program name.
awsBin = "aws"
// oracleBin is the Oracle CLI program name.
oracleBin = "sql"
// sqlclBin is the SQLcl program name (Oracle client).
sqlclBin = "sql"
// spannerBin is a Google Spanner interactive CLI program name.
spannerBin = "spanner-cli"
)
Expand Down Expand Up @@ -214,6 +214,7 @@ func (c *CLICommandBuilder) GetConnectCommand(ctx context.Context) (*exec.Cmd, e

case defaults.ProtocolClickHouseHTTP:
return c.getClickhouseHTTPCommand()

case defaults.ProtocolClickHouse:
return c.getClickhouseNativeCommand()

Expand All @@ -239,6 +240,8 @@ func (c *CLICommandBuilder) GetConnectCommandAlternatives(ctx context.Context) (
return c.getElasticsearchAlternativeCommands(), nil
case defaults.ProtocolOpenSearch:
return c.getOpenSearchAlternativeCommands(), nil
case defaults.ProtocolOracle:
return c.getOracleAlternativeCommands(), nil
}

cmd, err := c.GetConnectCommand(ctx)
Expand Down Expand Up @@ -786,38 +789,64 @@ func (c *CLICommandBuilder) getSpannerCommand() (*exec.Cmd, error) {
return cmd, nil
}

type jdbcOracleThinConnection struct {
host string
port int
db string
tnsAdmin string
func (c *CLICommandBuilder) getOracleTNSDescriptorString() string {
return fmt.Sprintf("/@(DESCRIPTION=(SDU=8000)(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)(HOST=%s)(PORT=%d)))(CONNECT_DATA=(SERVICE_NAME=%s)))", c.host, c.port, c.db.Database)
}

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) getOracleDirectConnectionString() string {
return fmt.Sprintf("/@%s:%d/%s", c.host, c.port, c.db.Database)
}

func (c *CLICommandBuilder) getOracleCommand() (*exec.Cmd, error) {
func (c *CLICommandBuilder) getOracleJDBCConnectionString() string {
tnsAdminPath := c.profile.OracleWalletDir(c.tc.SiteName, c.db.ServiceName)
if runtime.GOOS == constants.WindowsOS {
tnsAdminPath = strings.ReplaceAll(tnsAdminPath, `\`, `\\`)
}
cs := jdbcOracleThinConnection{
host: c.host,
port: c.port,
db: c.db.Database,
tnsAdmin: tnsAdminPath,
}
// Quote the address for printing as the address contains "?".
connString := cs.ConnString()
connString := fmt.Sprintf(`jdbc:oracle:thin:@tcps://%s:%d/%s?TNS_ADMIN=%s`, c.host, c.port, c.db.Database, tnsAdminPath)
if c.options.printFormat {
connString = fmt.Sprintf(`'%s'`, connString)
}
args := []string{
"-L", // dont retry
connString,
return connString
}

func (c *CLICommandBuilder) getOracleCommand() (*exec.Cmd, error) {
alternatives := c.getOracleAlternativeCommands()
if len(alternatives) == 0 {
return nil, trace.BadParameter("no alternative commands found")
}
return alternatives[0].Command, nil
}

func (c *CLICommandBuilder) getOracleAlternativeCommands() []CommandAlternative {
var commands []CommandAlternative

ctx := context.Background()

c.options.logger.DebugContext(ctx, "Building Oracle commands.")
c.options.logger.DebugContext(ctx, "Found servers with TCP support", "count", c.options.oracle.hasTCPServers)
c.options.logger.DebugContext(ctx, "All servers support TCP", "all_servers_support_tcp", c.options.oracle.canUseTCP)

c.options.logger.DebugContext(ctx, "Connection strings:")
c.options.logger.DebugContext(ctx, "JDBC", "connection_string", c.getOracleJDBCConnectionString())
if c.options.oracle.hasTCPServers {
c.options.logger.DebugContext(ctx, "TNS", "connection_string", c.getOracleTNSDescriptorString())
c.options.logger.DebugContext(ctx, "Direct", "connection_string", c.getOracleDirectConnectionString())
}
return exec.Command(oracleBin, args...), nil

const oneShotLogin = "-L"

commandTCP := exec.Command(sqlclBin, oneShotLogin, c.getOracleDirectConnectionString())
commandTCPS := exec.Command(sqlclBin, oneShotLogin, c.getOracleJDBCConnectionString())

if c.options.oracle.canUseTCP {
commands = append(commands, CommandAlternative{Description: "SQLcl", Command: commandTCP})
commands = append(commands, CommandAlternative{Description: "SQLcl (JDBC)", Command: commandTCPS})
} else {
commands = append(commands, CommandAlternative{Description: "SQLcl", Command: commandTCPS})
}

return commands
}

func (c *CLICommandBuilder) getElasticsearchAlternativeCommands() []CommandAlternative {
Expand Down Expand Up @@ -915,6 +944,7 @@ type connectionCommandOpts struct {
exe Execer
password string
gcp types.GCPCloudSQL
oracle oracleOpts
getDatabase GetDatabaseFunc
}

Expand Down Expand Up @@ -1005,6 +1035,19 @@ func WithExecer(exe Execer) ConnectCommandFunc {
}
}

type oracleOpts struct {
canUseTCP bool
hasTCPServers bool
}

// WithOracleOpts configures Oracle-specific options.
func WithOracleOpts(canUseTCP bool, hasTCPServers bool) ConnectCommandFunc {
return func(opts *connectionCommandOpts) {
opts.oracle.canUseTCP = canUseTCP
opts.oracle.hasTCPServers = hasTCPServers
}
}

// WithGCP adds GCP metadata for the database command to access.
// TODO(greedy52) use GetDatabaseFunc instead.
func WithGCP(gcp types.GCPCloudSQL) ConnectCommandFunc {
Expand Down
40 changes: 35 additions & 5 deletions tool/tsh/common/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,11 @@ type localProxyConfig struct {
}

func createLocalProxyListener(addr string, route tlsca.RouteToDatabase, profile *client.ProfileStatus) (net.Listener, error) {
l, err := net.Listen("tcp", addr)
if err != nil {
return nil, trace.Wrap(err)
}

if route.Protocol == defaults.ProtocolOracle {
localCert, err := tls.LoadX509KeyPair(
profile.DatabaseLocalCAPath(),
Expand All @@ -685,14 +690,15 @@ func createLocalProxyListener(addr string, route tlsca.RouteToDatabase, profile
if err != nil {
return nil, trace.Wrap(err)
}
l, err := tls.Listen("tcp", addr, &tls.Config{
config := &tls.Config{
Certificates: []tls.Certificate{localCert},
ServerName: "localhost",
})
return l, trace.Wrap(err)
}

l = NewTLSMuxListener(l, config)
}
l, err := net.Listen("tcp", addr)
return l, trace.Wrap(err)

return l, nil
}

// prepareLocalProxyOptions created localProxyOpts needed to create local proxy from localProxyConfig.
Expand Down Expand Up @@ -789,6 +795,7 @@ func onDatabaseConnect(cf *CLIConf) error {
if opts, err = maybeAddGCPMetadata(cf.Context, tc, dbInfo, opts); err != nil {
return trace.Wrap(err)
}
opts = maybeAddOracleOptions(cf.Context, tc, dbInfo, opts)

bb := dbcmd.NewCmdBuilder(tc, profile, dbInfo.RouteToDatabase, rootClusterName, opts...)
cmd, err := bb.GetConnectCommand(cf.Context)
Expand Down Expand Up @@ -1109,6 +1116,29 @@ func getDatabase(ctx context.Context, tc *client.TeleportClient, name string) (t
return databases[0], nil
}

func getDatabaseServers(ctx context.Context, tc *client.TeleportClient, name string) ([]types.DatabaseServer, error) {
var databases []types.DatabaseServer

err := client.RetryWithRelogin(ctx, tc, func() error {
matchName := makeNamePredicate(name)

var err error
predicate := makePredicateConjunction(matchName, tc.PredicateExpression)
log.Debugf("Listing databases with predicate (%v) and labels %v", predicate, tc.Labels)

databases, err = tc.ListDatabaseServersWithFilters(ctx, &proto.ListResourcesRequest{
Namespace: tc.Namespace,
ResourceType: types.KindDatabaseServer,
PredicateExpression: predicate,
Labels: tc.Labels,
UseSearchAsRoles: tc.UseSearchAsRoles,
})
return trace.Wrap(err)
})

return databases, trace.Wrap(err)
}

// getDatabaseByNameOrDiscoveredName fetches a database that unambiguously
// matches a given name or a discovered name label.
func getDatabaseByNameOrDiscoveredName(cf *CLIConf, tc *client.TeleportClient, activeRoutes []tlsca.RouteToDatabase) (types.Database, error) {
Expand Down
127 changes: 101 additions & 26 deletions tool/tsh/common/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"text/template"
"unicode"

"github.com/coreos/go-semver/semver"
"github.com/gravitational/trace"

"github.com/gravitational/teleport"
Expand Down Expand Up @@ -229,6 +230,7 @@ func onProxyCommandDB(cf *CLIConf) error {
if opts, err = maybeAddGCPMetadata(cf.Context, tc, dbInfo, opts); err != nil {
return trace.Wrap(err)
}
opts = maybeAddOracleOptions(cf.Context, tc, dbInfo, opts)

commands, err := dbcmd.NewCmdBuilder(tc, profile, dbInfo.RouteToDatabase, rootCluster,
opts...,
Expand Down Expand Up @@ -326,36 +328,112 @@ func maybeAddGCPMetadataTplArgs(ctx context.Context, tc *libclient.TeleportClien
}
}

func maybeAddOracleOptions(ctx context.Context, tc *libclient.TeleportClient, dbInfo *databaseInfo, opts []dbcmd.ConnectCommandFunc) []dbcmd.ConnectCommandFunc {
// Skip for non-Oracle protocols.
if dbInfo.Protocol != defaults.ProtocolOracle {
return opts
}

// TODO(Tener): DELETE IN 20.0.0 - all agents should now contain improved Oracle engine.
// minimum version to support TCPS-less connection.
cutoffVersion := semver.Version{
Major: 17,
Minor: 2,
Patch: 0,
PreRelease: "",
}

devV17Version := semver.Version{
Major: 17,
Minor: 0,
Patch: 0,
PreRelease: "dev",
}

dbServers, err := getDatabaseServers(ctx, tc, dbInfo.ServiceName)
if err != nil {
// log, but treat this error as non-fatal.
log.Warnf("Error getting database servers: %s", err.Error())
return opts
}

var oldServers, newServers int

for _, server := range dbServers {
ver, err := semver.NewVersion(server.GetTeleportVersion())
if err != nil {
log.Debugf("Failed to parse teleport version %q: %v", server.GetTeleportVersion(), err)
continue
}

if ver.Equal(devV17Version) {
newServers++
} else {
if ver.LessThan(cutoffVersion) {
oldServers++
} else {
newServers++
}
}
}

log.Debugf("Agents for database %q with Oracle support: total %v, old %v, new %v.", dbInfo.ServiceName, len(dbServers), oldServers, newServers)

if oldServers > 0 {
log.Warnf("Detected database agents older than %v. For improved client support upgrade all database agents in your cluster to a newer version.", cutoffVersion)
}

opts = append(opts, dbcmd.WithOracleOpts(oldServers == 0, newServers > 0))
return opts
}

type templateCommandItem struct {
Description string
Command string
}

func chooseProxyCommandTemplate(templateArgs map[string]any, commands []dbcmd.CommandAlternative, dbInfo *databaseInfo) *template.Template {
// there is only one command, use plain template.
if len(commands) == 1 {
templateArgs["command"] = formatCommand(commands[0].Command)
switch dbInfo.Protocol {
case defaults.ProtocolOracle:
templateArgs["args"] = commands[0].Command.Args
return dbProxyOracleAuthTpl
case defaults.ProtocolSpanner:
templateArgs["databaseName"] = "<database>"
if dbInfo.Database != "" {
templateArgs["databaseName"] = dbInfo.Database
templateArgs["command"] = formatCommand(commands[0].Command)

// protocol-specific templates
if dbInfo.Protocol == defaults.ProtocolOracle {
// the JDBC connection string should always be found,
// but the order of commands is important as only the first command will actually be shown.
jdbcConnectionString := ""
ixFound := -1
for ix, cmd := range commands {
for _, arg := range cmd.Command.Args {
if strings.Contains(arg, "jdbc:oracle:") {
jdbcConnectionString = arg
ixFound = ix
}
}
return dbProxySpannerAuthTpl
}
templateArgs["jdbcConnectionString"] = jdbcConnectionString
templateArgs["canUseTCP"] = ixFound > 0
return dbProxyOracleAuthTpl
}

if dbInfo.Protocol == defaults.ProtocolSpanner {
templateArgs["databaseName"] = "<database>"
if dbInfo.Database != "" {
templateArgs["databaseName"] = dbInfo.Database
}
return dbProxySpannerAuthTpl
}

// there is only one command, use plain template.
if len(commands) == 1 {
return dbProxyAuthTpl
}

// multiple command options, use a different template.

var commandsArg []templateCommandItem
for _, cmd := range commands {
commandsArg = append(commandsArg, templateCommandItem{cmd.Description, formatCommand(cmd.Command)})
}

delete(templateArgs, "command")
templateArgs["commands"] = commandsArg
return dbProxyAuthMultiTpl
}
Expand Down Expand Up @@ -686,10 +764,6 @@ Your database user is "{{.databaseUser}}".{{if .databaseName}} The target databa
`))

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}}.
Expand All @@ -713,21 +787,22 @@ Or use the following JDBC connection string to connect with other GUI/CLI client
jdbc:cloudspanner://{{.address}}/projects/{{.gcpProject}}/instances/{{.gcpInstance}}/databases/{{.databaseName}};usePlainText=true
`))

// dbProxyOracleAuthTpl is the message that's printed for an authenticated db proxy.
var dbProxyOracleAuthTpl = template.Must(template.New("").Funcs(templateFunctions).Parse(
var dbProxyOracleAuthTpl = template.Must(template.New("").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}}
` + dbProxyConnectAd + `
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}}
{{if .canUseTCP }}Other clients can use:
- a direct connection to {{.address}} without a username and password
- a custom JDBC connection string: {{.jdbcConnectionString}}
{{else }}You can also connect using Oracle JDBC connection string:
{{.jdbcConnectionString}}
Note: for improved client compatibility, upgrade your Teleport cluster. For details rerun this command with --debug.
{{- end }}
`))

// dbProxyAuthMultiTpl is the message that's printed for an authenticated db proxy if there are multiple command options.
Expand Down
Loading

0 comments on commit 7b0de41

Please sign in to comment.