mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-02 23:31:08 +08:00
304 lines
8.9 KiB
Go
304 lines
8.9 KiB
Go
// Teleport
|
|
// Copyright (C) 2024 Gravitational, Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package repl
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gravitational/trace"
|
|
"github.com/jackc/pgconn"
|
|
"golang.org/x/term"
|
|
|
|
"github.com/gravitational/teleport"
|
|
clientproto "github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/lib/asciitable"
|
|
dbrepl "github.com/gravitational/teleport/lib/client/db/repl"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
)
|
|
|
|
type REPL struct {
|
|
connConfig *pgconn.Config
|
|
client io.ReadWriteCloser
|
|
serverConn net.Conn
|
|
route clientproto.RouteToDatabase
|
|
term *term.Terminal
|
|
commands map[string]*command
|
|
}
|
|
|
|
func New(_ context.Context, cfg *dbrepl.NewREPLConfig) (dbrepl.REPLInstance, error) {
|
|
config, err := pgconn.ParseConfig(fmt.Sprintf("postgres://%s", hostnamePlaceholder))
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
config.User = cfg.Route.Username
|
|
config.Database = cfg.Route.Database
|
|
config.ConnectTimeout = defaults.DatabaseConnectTimeout
|
|
config.RuntimeParams = map[string]string{
|
|
applicationNameParamName: applicationNameParamValue,
|
|
}
|
|
config.TLSConfig = nil
|
|
|
|
// Provide a lookup function to avoid having the hostname placeholder to
|
|
// resolve into something else. Note that the returned value won't be used.
|
|
config.LookupFunc = func(_ context.Context, _ string) ([]string, error) {
|
|
return []string{hostnamePlaceholder}, nil
|
|
}
|
|
config.DialFunc = func(_ context.Context, _, _ string) (net.Conn, error) {
|
|
return cfg.ServerConn, nil
|
|
}
|
|
|
|
return &REPL{
|
|
connConfig: config,
|
|
client: cfg.Client,
|
|
serverConn: cfg.ServerConn,
|
|
route: cfg.Route,
|
|
term: term.NewTerminal(cfg.Client, ""),
|
|
commands: initCommands(),
|
|
}, nil
|
|
}
|
|
|
|
// Run starts and run the PostgreSQL REPL session. The provided context is used
|
|
// to interrupt the execution and clean up resources.
|
|
func (r *REPL) Run(ctx context.Context) error {
|
|
pgConn, err := pgconn.ConnectConfig(ctx, r.connConfig)
|
|
if err != nil {
|
|
return trace.ConnectionProblem(err, "Unable to connect to database: %v", err)
|
|
}
|
|
defer func() {
|
|
closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
pgConn.Close(closeCtx)
|
|
}()
|
|
|
|
// term.Terminal blocks reads/writes without respecting the context. The
|
|
// only thing that unblocks it is closing the underlaying connection (in
|
|
// our case r.client). On this goroutine we only watch for context
|
|
// cancelation and close the connection. This will unblocks all terminal
|
|
// reads/writes.
|
|
ctxCancelCh := make(chan struct{})
|
|
defer close(ctxCancelCh)
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = r.client.Close()
|
|
case <-ctxCancelCh:
|
|
}
|
|
}()
|
|
|
|
if err := r.presentBanner(); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
var (
|
|
multilineAcc strings.Builder
|
|
readingMultiline bool
|
|
)
|
|
|
|
lead := lineLeading(r.route)
|
|
leadSpacing := strings.Repeat(" ", len(lead))
|
|
r.term.SetPrompt(lineBreak + lead)
|
|
|
|
for {
|
|
line, err := r.term.ReadLine()
|
|
if err != nil {
|
|
return trace.Wrap(formatTermError(ctx, err))
|
|
}
|
|
|
|
// ReadLine should always return the line without trailing line breaks,
|
|
// but we still require to remove trailing and leading spaces.
|
|
line = strings.TrimSpace(line)
|
|
|
|
var reply string
|
|
switch {
|
|
case strings.HasPrefix(line, commandPrefix) && !readingMultiline:
|
|
var exit bool
|
|
reply, exit = r.processCommand(line)
|
|
if exit {
|
|
return nil
|
|
}
|
|
case strings.HasSuffix(line, executionRequestSuffix):
|
|
var query string
|
|
if readingMultiline {
|
|
multilineAcc.WriteString(lineBreak + line)
|
|
query = multilineAcc.String()
|
|
} else {
|
|
query = line
|
|
}
|
|
|
|
// Reset multiline state.
|
|
multilineAcc.Reset()
|
|
readingMultiline = false
|
|
r.term.SetPrompt(lineBreak + lead)
|
|
|
|
reply = formatResult(pgConn.Exec(ctx, query).ReadAll()) + lineBreak
|
|
default:
|
|
// If there wasn't a specific execution, we assume the input is
|
|
// multi-line. In this case, we need to accumulate the contents.
|
|
|
|
// If this isn't the first line, add the line break as the
|
|
// ReadLine function removes it.
|
|
if readingMultiline {
|
|
multilineAcc.WriteString(lineBreak)
|
|
}
|
|
|
|
readingMultiline = true
|
|
multilineAcc.WriteString(line)
|
|
r.term.SetPrompt(leadSpacing)
|
|
}
|
|
|
|
if reply == "" {
|
|
continue
|
|
}
|
|
|
|
if _, err := r.term.Write([]byte(reply)); err != nil {
|
|
return trace.Wrap(formatTermError(ctx, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// formatTermError changes the term.Terminal error to match caller expectations.
|
|
func formatTermError(ctx context.Context, err error) error {
|
|
// When context is canceled it will immediately lead read/write errors due
|
|
// to the closed connection. For this cases we return the context error.
|
|
if ctx.Err() != nil && (errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed)) {
|
|
return ctx.Err()
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (r *REPL) presentBanner() error {
|
|
_, err := fmt.Fprintf(
|
|
r.term,
|
|
`Teleport PostgreSQL interactive shell (v%s)
|
|
Connected to %q instance as %q user.
|
|
Type \? for help.`,
|
|
teleport.Version,
|
|
r.route.GetServiceName(),
|
|
r.route.GetUsername())
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// formatResult formats a pgconn.Exec result.
|
|
func formatResult(results []*pgconn.Result, err error) string {
|
|
if err != nil {
|
|
return errorReplyPrefix + err.Error()
|
|
}
|
|
|
|
var (
|
|
sb strings.Builder
|
|
resultsLen = len(results)
|
|
)
|
|
for i, res := range results {
|
|
if !res.CommandTag.Select() {
|
|
return res.CommandTag.String()
|
|
}
|
|
|
|
// build columns
|
|
var columns []string
|
|
for _, fd := range res.FieldDescriptions {
|
|
columns = append(columns, string(fd.Name))
|
|
}
|
|
|
|
table := asciitable.MakeTable(columns)
|
|
for _, row := range res.Rows {
|
|
rowData := make([]string, len(columns))
|
|
for i, data := range row {
|
|
// The PostgreSQL package is responsible for transforming the
|
|
// row data into a readable format.
|
|
rowData[i] = string(data)
|
|
}
|
|
|
|
table.AddRow(rowData)
|
|
}
|
|
|
|
table.AsBuffer().WriteTo(&sb)
|
|
sb.WriteString(rowsText(len(res.Rows)))
|
|
|
|
// Add line breaks to separate results. Except the last result, which
|
|
// will have line breaks added later in the reply.
|
|
if i != resultsLen-1 {
|
|
sb.WriteString(lineBreak + lineBreak)
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func lineLeading(route clientproto.RouteToDatabase) string {
|
|
return fmt.Sprintf("%s=> ", route.Database)
|
|
}
|
|
|
|
func rowsText(count int) string {
|
|
rowTxt := "row"
|
|
if count > 1 {
|
|
rowTxt = "rows"
|
|
}
|
|
|
|
return fmt.Sprintf("(%d %s)", count, rowTxt)
|
|
}
|
|
|
|
const (
|
|
// hostnamePlaceholder is the hostname used when connecting to the database.
|
|
// The pgconn functions require a hostname, however, since we already have
|
|
// the connection, we just need to provide a name to suppress this
|
|
// requirement.
|
|
hostnamePlaceholder = "repl"
|
|
// lineBreak represents a line break on the REPL.
|
|
lineBreak = "\r\n"
|
|
// commandPrefix is the prefix that identifies a REPL command.
|
|
commandPrefix = "\\"
|
|
// executionRequestSuffix is the suffix that indicates the input must be
|
|
// executed.
|
|
executionRequestSuffix = ";"
|
|
// errorReplyPrefix is the prefix presented when there is a execution error.
|
|
errorReplyPrefix = "ERR "
|
|
)
|
|
|
|
const (
|
|
// applicationNameParamName defines the application name parameter name.
|
|
//
|
|
// https://www.postgresql.org/docs/17/libpq-connect.html#LIBPQ-CONNECT-APPLICATION-NAME
|
|
applicationNameParamName = "application_name"
|
|
// applicationNameParamValue defines the application name parameter value.
|
|
applicationNameParamValue = "teleport-repl"
|
|
)
|
|
|
|
// descriptiveLimitations defines a user-friendly text containing the REPL
|
|
// limitations.
|
|
var descriptiveLimitations = []string{
|
|
`Query cancellation is not supported. Once a query is sent, its execution
|
|
cannot be canceled. Note that Teleport sends a terminate message to the database
|
|
when the database session terminates. This flow doesn't guarantee that any
|
|
running queries will be canceled.
|
|
See https://www.postgresql.org/docs/17/protocol-flow.html#PROTOCOL-FLOW-TERMINATION for more details on the termination flow.`,
|
|
// This limitation is due to our terminal emulator not fully supporting this
|
|
// shortcut's custom handler. Instead, it will close the terminal, leading
|
|
// to terminating the session. To avoid having users accidentally
|
|
// terminating their sessions, we're turning this off until we have a better
|
|
// solution and propose the behavior for it.
|
|
//
|
|
// This shortcut filtered out by the WebUI key handler.
|
|
"Pressing CTRL-C will have no effect in this shell.",
|
|
}
|