Files
teleport/lib/web/command.go
T
rosstimothy e93208c2a5 Fix hostname resolution for connections in leaf clusters (#38358)
The changes to hostname resolution introduced in #35773 were always
using the root cache to lookup a matching node, which is never
going to find a match if the target is in the leaf cluster. This
is now resolved by using the `services.NodeWatcher` of the cluster
that the target node is a member of.
2024-02-20 22:54:10 +00:00

769 lines
24 KiB
Go

/*
* Teleport
* Copyright (C) 2023 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 web
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gogo/protobuf/proto"
"github.com/gorilla/websocket"
"github.com/gravitational/trace"
"github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
oteltrace "go.opentelemetry.io/otel/trace"
"golang.org/x/crypto/ssh"
"golang.org/x/sync/errgroup"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/gravitational/teleport"
clientproto "github.com/gravitational/teleport/api/client/proto"
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/gen/proto/go/assist/v1"
usageeventsv1 "github.com/gravitational/teleport/api/gen/proto/go/usageevents/v1"
"github.com/gravitational/teleport/api/observability/tracing"
"github.com/gravitational/teleport/lib/agentless"
"github.com/gravitational/teleport/lib/ai/tokens"
assistlib "github.com/gravitational/teleport/lib/assist"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/httplib"
"github.com/gravitational/teleport/lib/proxy"
"github.com/gravitational/teleport/lib/reversetunnelclient"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/teleagent"
)
// summaryBufferCapacity is the summary buffer size in bytes. The summary buffer
// is shared across all nodes the command is running on and stores the command
// output. If the command output exceeds the buffer capacity, the summary won't
// be computed.
const summaryBufferCapacity = 2000
// CommandRequest is a request to execute a command on all nodes that match the query.
type CommandRequest struct {
// Command is the command to be executed on all nodes.
Command string `json:"command"`
// Login is a Linux username to connect as.
Login string `json:"login"`
// Query is the predicate query to filter nodes where the command will be executed.
Query string `json:"query"`
// ConversationID is the conversation context that was used to execute the command.
ConversationID string `json:"conversation_id"`
// ExecutionID is a unique ID used to identify the command execution.
ExecutionID string `json:"execution_id"`
}
// commandExecResult is a result of a command execution.
type commandExecResult struct {
// NodeID is the ID of the node where the command was executed.
NodeID string `json:"node_id"`
// NodeName is the name of the node where the command was executed.
NodeName string `json:"node_name"`
// ExecutionID is a unique ID used to identify the command execution.
ExecutionID string `json:"execution_id"`
// SessionID is the ID of the session where the command was executed.
SessionID string `json:"session_id"`
}
// sessionEndEvent is an event that is sent when a session ends.
type sessionEndEvent struct {
// NodeID is the ID of the server where the session was created.
NodeID string `json:"node_id"`
}
// Check checks if the request is valid.
func (c *CommandRequest) Check() error {
if c.Command == "" {
return trace.BadParameter("missing command")
}
if c.Query == "" {
return trace.BadParameter("missing query")
}
if c.Login == "" {
return trace.BadParameter("missing login")
}
if c.ConversationID == "" {
return trace.BadParameter("missing conversation ID")
}
if c.ExecutionID == "" {
return trace.BadParameter("missing execution ID")
}
return nil
}
// executeCommand executes a command on all nodes that match the query.
func (h *Handler) executeCommand(
w http.ResponseWriter,
r *http.Request,
_ httprouter.Params,
sessionCtx *SessionContext,
site reversetunnelclient.RemoteSite,
rawWS *websocket.Conn,
) (any, error) {
q := r.URL.Query()
params := q.Get("params")
if params == "" {
return nil, trace.BadParameter("missing params")
}
var req CommandRequest
if err := json.Unmarshal([]byte(params), &req); err != nil {
return nil, trace.BadParameter("failed to read JSON message: %v", err)
}
if err := req.Check(); err != nil {
return nil, trace.BadParameter("invalid payload: %v", err)
}
clt, err := sessionCtx.GetUserClient(r.Context(), site)
if err != nil {
return nil, trace.Wrap(err)
}
if err := checkAssistEnabled(clt, r.Context()); err != nil {
return nil, trace.Wrap(err)
}
ctx, err := h.cfg.SessionControl.AcquireSessionContext(r.Context(), sessionCtx, req.Login, h.cfg.ProxyWebAddr.Addr, r.RemoteAddr)
if err != nil {
return nil, trace.Wrap(err)
}
authAccessPoint, err := site.CachingAccessPoint()
if err != nil {
h.log.WithError(err).Debug("Unable to get auth access point.")
return nil, trace.Wrap(err)
}
netConfig, err := authAccessPoint.GetClusterNetworkingConfig(ctx)
if err != nil {
h.log.WithError(err).Debug("Unable to fetch cluster networking config.")
return nil, trace.Wrap(err)
}
clusterName := site.GetName()
defer func() {
rawWS.WriteMessage(websocket.CloseMessage, nil)
rawWS.Close()
}()
keepAliveInterval := netConfig.GetKeepAliveInterval()
err = rawWS.SetReadDeadline(deadlineForInterval(keepAliveInterval))
if err != nil {
h.log.WithError(err).Error("Error setting websocket readline")
return nil, trace.Wrap(err)
}
// Update the read deadline upon receiving a pong message.
rawWS.SetPongHandler(func(_ string) error {
// This is intentonally called without a lock as this callback is
// called from the same goroutine as the read loop which is already locked.
return trace.Wrap(rawWS.SetReadDeadline(deadlineForInterval(keepAliveInterval)))
})
// Wrap the raw websocket connection in a syncRWWSConn so that we can
// safely read and write to the the single websocket connection from
// multiple goroutines/execution nodes.
ws := &syncRWWSConn{WSConn: rawWS}
hosts, err := findByQuery(ctx, clt, req.Query)
if err != nil {
log.WithError(err).Warn("Failed to find nodes by labels")
return nil, trace.Wrap(err)
}
if len(hosts) == 0 {
const errMsg = "no servers found"
h.log.Error(errMsg)
return nil, trace.Errorf(errMsg)
}
h.log.Debugf("Found %d hosts to run Assist command %q on.", len(hosts), req.Command)
mfaCacheFn := getMFACacheFn()
interactiveCommand := strings.Split(req.Command, " ")
buffer := newSummaryBuffer(summaryBufferCapacity)
runCmd := func(host *hostInfo) error {
sessionData, err := h.generateCommandSession(host, req.Login, clusterName, sessionCtx.cfg.User)
if err != nil {
h.log.WithError(err).Debug("Unable to generate new ssh session.")
return trace.Wrap(err)
}
h.log.Debugf("New command request for server=%s, id=%v, login=%s, sid=%s, websid=%s.",
host.hostName, host.id, req.Login, sessionData.ID, sessionCtx.GetSessionID())
commandHandlerConfig := CommandHandlerConfig{
SessionCtx: sessionCtx,
UserAuthClient: clt,
SessionData: sessionData,
KeepAliveInterval: keepAliveInterval,
ProxyHostPort: h.ProxyHostPort(),
InteractiveCommand: interactiveCommand,
Router: h.cfg.Router,
TracerProvider: h.cfg.TracerProvider,
LocalAccessPoint: h.auth.accessPoint,
mfaFuncCache: mfaCacheFn,
buffer: buffer,
HostNameResolver: func(serverID string) (string, error) {
return serverID, nil
},
}
handler, err := newCommandHandler(ctx, commandHandlerConfig)
if err != nil {
h.log.WithError(err).Error("Unable to create terminal.")
return trace.Wrap(err)
}
handler.ws = &noopCloserWS{ws}
h.userConns.Add(1)
defer h.userConns.Add(-1)
h.log.Infof("Executing command: %#v.", req)
httplib.MakeTracingHandler(handler, teleport.ComponentProxy).ServeHTTP(w, r)
msgPayload, err := json.Marshal(&commandExecResult{
NodeID: host.id,
NodeName: host.hostName,
ExecutionID: req.ExecutionID,
SessionID: string(sessionData.ID),
})
if err != nil {
return trace.Wrap(err)
}
err = clt.CreateAssistantMessage(ctx, &assist.CreateAssistantMessageRequest{
ConversationId: req.ConversationID,
Username: sessionCtx.GetUser(),
Message: &assist.AssistantMessage{
Type: string(assistlib.MessageKindCommandResult),
CreatedTime: timestamppb.New(time.Now().UTC()),
Payload: string(msgPayload),
},
})
return trace.Wrap(err)
}
runCommands(hosts, runCmd, int(netConfig.GetAssistCommandExecutionWorkers()), h.log)
var tokenCount *tokens.TokenCount
// Optionally, try to compute the command summary.
if output, valid := buffer.Export(); valid {
summaryReq := summaryRequest{
hosts: hosts,
output: output,
authClient: clt,
username: sessionCtx.GetUser(),
executionID: req.ExecutionID,
conversationID: req.ConversationID,
command: req.Command,
}
tokenCount, err = h.computeAndSendSummary(ctx, &summaryReq, ws)
if err != nil {
return nil, trace.Wrap(err)
}
}
prompt, completion := tokens.CountTokens(tokenCount)
usageEventReq := &clientproto.SubmitUsageEventRequest{
Event: &usageeventsv1.UsageEventOneOf{
Event: &usageeventsv1.UsageEventOneOf_AssistExecution{
AssistExecution: &usageeventsv1.AssistExecutionEvent{
ConversationId: req.ConversationID,
NodeCount: int64(len(hosts)),
TotalTokens: int64(completion + prompt),
PromptTokens: int64(prompt),
CompletionTokens: int64(completion),
},
},
},
}
if err := clt.SubmitUsageEvent(ctx, usageEventReq); err != nil {
h.log.WithError(err).Warn("Failed to emit usage event")
}
return nil, nil
}
type summaryRequest struct {
hosts []hostInfo
output map[string][]byte
authClient auth.ClientI
username string
executionID string
conversationID string
command string
}
func (h *Handler) computeAndSendSummary(
ctx context.Context,
req *summaryRequest,
ws WSConn,
) (*tokens.TokenCount, error) {
// Convert the map nodeId->output into a map nodeName->output
namedOutput := outputByName(req.hosts, req.output)
history, err := req.authClient.GetAssistantMessages(ctx, &assist.GetAssistantMessagesRequest{
ConversationId: req.conversationID,
Username: req.username,
})
if err != nil {
return nil, trace.Wrap(err)
}
assistClient, err := assistlib.NewClient(ctx, req.authClient, h.cfg.ProxySettings, h.cfg.OpenAIConfig)
if err != nil {
return nil, trace.Wrap(err)
}
summary, tokenCount, err := assistClient.GenerateCommandSummary(ctx, history.GetMessages(), namedOutput)
if err != nil {
return nil, trace.Wrap(err)
}
// Add the summary message to the backend, so it is persisted on chat
// reload.
messagePayload, err := json.Marshal(&assistlib.CommandExecSummary{
ExecutionID: req.executionID,
Command: req.command,
Summary: summary,
})
if err != nil {
return nil, trace.Wrap(err)
}
summaryMessage := &assist.CreateAssistantMessageRequest{
ConversationId: req.conversationID,
Username: req.username,
Message: &assist.AssistantMessage{
Type: string(assistlib.MessageKindCommandResultSummary),
CreatedTime: timestamppb.New(time.Now().UTC()),
Payload: string(messagePayload),
},
}
err = req.authClient.CreateAssistantMessage(ctx, summaryMessage)
if err != nil {
return nil, trace.Wrap(err)
}
// Send the summary over the execution websocket to provide instant
// feedback to the user.
out := &outEnvelope{
Type: envelopeTypeSummary,
Payload: []byte(summary),
}
data, err := json.Marshal(out)
if err != nil {
return nil, trace.Wrap(err)
}
stream := NewWStream(ctx, ws, log, nil)
_, err = stream.Write(data)
return tokenCount, trace.Wrap(err)
}
func outputByName(hosts []hostInfo, output map[string][]byte) map[string][]byte {
hostIDToName := make(map[string]string, len(hosts))
for _, host := range hosts {
hostIDToName[host.id] = host.hostName
}
namedOutput := make(map[string][]byte, len(output))
for id, data := range output {
namedOutput[hostIDToName[id]] = data
}
return namedOutput
}
// runCommands runs the given command on the given hosts.
func runCommands(hosts []hostInfo, runCmd func(host *hostInfo) error, numParallel int, log logrus.FieldLogger) {
var group errgroup.Group
group.SetLimit(numParallel)
for _, host := range hosts {
host := host
group.Go(func() error {
return trace.Wrap(runCmd(&host), "failed to start session on %v", host.hostName)
})
}
// Wait for all commands to finish.
if err := group.Wait(); err != nil {
log.WithError(err).Debug("Assist command execution failed")
}
}
// getMFACacheFn returns a function that caches the result of the given
// get function. The cache is protected by a mutex, so it is safe to call
// the returned function from multiple goroutines.
func getMFACacheFn() mfaFuncCache {
var mutex sync.Mutex
var authMethods []ssh.AuthMethod
return func(issueMfaAuthFn func() ([]ssh.AuthMethod, error)) ([]ssh.AuthMethod, error) {
mutex.Lock()
defer mutex.Unlock()
if authMethods != nil {
return authMethods, nil
}
authMethods, err := issueMfaAuthFn()
return authMethods, trace.Wrap(err)
}
}
func newCommandHandler(ctx context.Context, cfg CommandHandlerConfig) (*commandHandler, error) {
err := cfg.CheckAndSetDefaults()
if err != nil {
return nil, trace.Wrap(err)
}
_, span := cfg.tracer.Start(ctx, "NewCommand")
defer span.End()
return &commandHandler{
sshBaseHandler: sshBaseHandler{
log: logrus.WithFields(logrus.Fields{
trace.Component: teleport.ComponentWebsocket,
"session_id": cfg.SessionData.ID.String(),
}),
ctx: cfg.SessionCtx,
userAuthClient: cfg.UserAuthClient,
sessionData: cfg.SessionData,
keepAliveInterval: cfg.KeepAliveInterval,
proxyHostPort: cfg.ProxyHostPort,
interactiveCommand: cfg.InteractiveCommand,
router: cfg.Router,
localAccessPoint: cfg.LocalAccessPoint,
tracer: cfg.tracer,
resolver: cfg.HostNameResolver,
},
mfaAuthCache: cfg.mfaFuncCache,
buffer: cfg.buffer,
}, nil
}
// CommandHandlerConfig is the configuration for the command handler.
type CommandHandlerConfig struct {
// SessionCtx is the context for the user's web session.
SessionCtx *SessionContext
// UserAuthClient is used to fetch nodes and sessions from the backend via the users' identity.
UserAuthClient UserAuthClient
// SessionData is the data to send to the client on the initial session creation.
SessionData session.Session
// KeepAliveInterval is the interval for sending ping frames to a web client.
// This value is pulled from the cluster network config and
// guaranteed to be set to a nonzero value as it's enforced by the configuration.
KeepAliveInterval time.Duration
// ProxyHostPort is the address of the server to connect to.
ProxyHostPort string
// InteractiveCommand is a command to execute.
InteractiveCommand []string
// Router determines how connections to nodes are created
Router *proxy.Router
// TracerProvider is used to create the tracer
TracerProvider oteltrace.TracerProvider
// LocalAccessPoint is the subset of the Proxy cache required to
// look up information from the local cluster. This should not
// be used for anything that requires RBAC on behalf of the user.
// Anything requests that should be made on behalf of the user should
// use [UserAuthClient].
LocalAccessPoint localAccessPoint
// HostNameResolver allows the hostname to be determined from a server UUID
// so that a friendly name can be displayed in the console tab.
HostNameResolver func(serverID string) (hostname string, err error)
// tracer is used to create spans
tracer oteltrace.Tracer
// mfaFuncCache is used to cache the MFA auth method
mfaFuncCache mfaFuncCache
// buffer shared across multiple commandHandlers that saves the command
// output in order to generate a summary of the executed commands.
buffer *summaryBuffer
}
// CheckAndSetDefaults checks and sets default values.
func (t *CommandHandlerConfig) CheckAndSetDefaults() error {
// Make sure whatever session is requested is a valid session id.
_, err := session.ParseID(t.SessionData.ID.String())
if err != nil {
return trace.BadParameter("sid: invalid session id")
}
if t.SessionData.Login == "" {
return trace.BadParameter("login: missing login")
}
if t.SessionData.ServerID == "" {
return trace.BadParameter("server: missing server")
}
if t.UserAuthClient == nil {
return trace.BadParameter("UserAuthClient must be provided")
}
if t.SessionCtx == nil {
return trace.BadParameter("SessionCtx must be provided")
}
if t.Router == nil {
return trace.BadParameter("Router must be provided")
}
if t.TracerProvider == nil {
t.TracerProvider = tracing.DefaultProvider()
}
if t.LocalAccessPoint == nil {
return trace.BadParameter("localAccessPoint must be provided")
}
if t.mfaFuncCache == nil {
return trace.BadParameter("mfaFuncCache must be provided")
}
t.tracer = t.TracerProvider.Tracer("webcommand")
return nil
}
// mfaFuncCache is a function type that caches the result of a function that
// returns a list of ssh.AuthMethods.
type mfaFuncCache func(func() ([]ssh.AuthMethod, error)) ([]ssh.AuthMethod, error)
// commandHandler is a handler for executing commands on a remote node.
type commandHandler struct {
sshBaseHandler
// stream is the websocket stream to the client.
stream *WSStream
// ws a raw websocket connection to the client.
ws WSConn
// mfaAuthCache is a function that caches the result of a function that
// returns a list of ssh.AuthMethods. It is used to cache the result of
// the MFA challenge.
mfaAuthCache mfaFuncCache
// buffer shared across multiple commandHandlers that saves the command
// output in order to generate a summary of the executed commands.
buffer *summaryBuffer
}
// sendError sends an error message to the client using the provided websocket.
func (t *sshBaseHandler) sendError(errMsg string, err error, ws WSConn) {
envelope := &Envelope{
Version: defaults.WebsocketVersion,
Type: defaults.WebsocketError,
Payload: fmt.Sprintf("%s: %s", errMsg, err.Error()),
}
envelopeBytes, err := proto.Marshal(envelope)
if err != nil {
t.log.WithError(err).Error("failed to marshal error message")
}
if err := ws.WriteMessage(websocket.BinaryMessage, envelopeBytes); err != nil {
t.log.WithError(err).Error("failed to send error message")
}
}
func (t *commandHandler) ServeHTTP(_ http.ResponseWriter, r *http.Request) {
// Allow closing websocket if the user logs out before exiting
// the session.
t.ctx.AddClosers(t)
defer t.ctx.RemoveCloser(t)
sessionMetadataResponse, err := json.Marshal(siteSessionGenerateResponse{Session: t.sessionData})
if err != nil {
t.sendError("unable to marshal session response", err, t.ws)
return
}
envelope := &Envelope{
Version: defaults.WebsocketVersion,
Type: defaults.WebsocketSessionMetadata,
Payload: string(sessionMetadataResponse),
}
envelopeBytes, err := proto.Marshal(envelope)
if err != nil {
t.sendError("unable to marshal session data event for web client", err, t.ws)
return
}
err = t.ws.WriteMessage(websocket.BinaryMessage, envelopeBytes)
if err != nil {
t.sendError("unable to write message to socket", err, t.ws)
return
}
t.handler(r)
}
func (t *commandHandler) handler(r *http.Request) {
t.stream = NewWStream(r.Context(), t.ws, t.log, nil)
// Create a Teleport client, if not able to, show the reason to the user in
// the terminal.
tc, err := t.makeClient(r.Context(), t.ws)
if err != nil {
t.log.WithError(err).Info("Failed creating a client for session")
t.writeError(err)
return
}
t.log.Debug("Creating websocket stream")
// Start sending ping frames through websocket to the client.
go startPingLoop(r.Context(), t.ws, t.keepAliveInterval, t.log, t.Close)
// Pump raw terminal in/out and audit events into the websocket.
t.streamOutput(r.Context(), tc)
}
// streamOutput opens an SSH connection to the remote host and streams
// events back to the web client.
func (t *commandHandler) streamOutput(ctx context.Context, tc *client.TeleportClient) {
ctx, span := t.tracer.Start(ctx, "commandHandler/streamOutput")
defer span.End()
nc, err := t.connectToHost(ctx, t.ws, tc, t.connectToNodeWithMFA)
if err != nil {
t.log.WithError(err).Warn("Unable to stream terminal - failure connecting to host")
t.writeError(err)
return
}
defer nc.Close()
// Enable session recording
nc.AddEnv(teleport.EnableNonInteractiveSessionRecording, "true")
// Establish SSH connection to the server. This function will block until
// either an error occurs or it completes successfully.
if err = nc.RunCommand(ctx, t.interactiveCommand); err != nil {
t.log.WithError(err).Warn("Unable to stream terminal - failure running shell")
t.writeError(err)
return
}
if err := t.stream.SendCloseMessage(sessionEndEvent{NodeID: t.sessionData.ServerID}); err != nil {
t.log.WithError(err).Error("Unable to send close event to web client.")
return
}
t.log.Debug("Sent close event to web client.")
}
// connectToNodeWithMFA attempts to perform the mfa ceremony and then dial the
// host with the retrieved single use certs.
// If called multiple times, the mfa ceremony will only be performed once.
func (t *commandHandler) connectToNodeWithMFA(ctx context.Context, ws WSConn, tc *client.TeleportClient, accessChecker services.AccessChecker, getAgent teleagent.Getter, signer agentless.SignerCreator) (*client.NodeClient, error) {
authMethods, err := t.mfaAuthCache(func() ([]ssh.AuthMethod, error) {
// perform mfa ceremony and retrieve new certs
authMethods, err := t.issueSessionMFACerts(ctx, tc, t.stream)
if err != nil {
return nil, trace.Wrap(err)
}
return authMethods, nil
})
if err != nil {
return nil, trace.Wrap(err)
}
return t.connectToNodeWithMFABase(ctx, ws, tc, accessChecker, getAgent, signer, authMethods)
}
// Close is no-op as we never want to close the connection to the client.
// Connection should be closed in the handler when it was created.
func (t *commandHandler) Close() error {
return nil
}
// makeClient builds a *client.TeleportClient for the connection.
func (t *commandHandler) makeClient(ctx context.Context, ws WSConn) (*client.TeleportClient, error) {
ctx, span := tracing.DefaultProvider().Tracer("command").Start(ctx, "commandHandler/makeClient")
defer span.End()
clientConfig, err := makeTeleportClientConfig(ctx, t.ctx)
if err != nil {
return nil, trace.Wrap(err)
}
clientConfig.HostLogin = t.sessionData.Login
clientConfig.ForwardAgent = client.ForwardAgentLocal
clientConfig.Namespace = apidefaults.Namespace
clientConfig.Stdout = newBufferedPayloadWriter(newPayloadWriter(t.sessionData.ServerID, EnvelopeTypeStdout, t.stream), t.buffer)
clientConfig.Stderr = newBufferedPayloadWriter(newPayloadWriter(t.sessionData.ServerID, envelopeTypeStderr, t.stream), t.buffer)
clientConfig.Stdin = &bytes.Buffer{} // set stdin to a dummy buffer
clientConfig.SiteName = t.sessionData.ClusterName
if err := clientConfig.ParseProxyHost(t.proxyHostPort); err != nil {
return nil, trace.BadParameter("failed to parse proxy address: %v", err)
}
clientConfig.Host = t.sessionData.ServerHostname
clientConfig.HostPort = t.sessionData.ServerHostPort
clientConfig.SessionID = t.sessionData.ID.String()
clientConfig.ClientAddr = ws.RemoteAddr().String()
clientConfig.Tracer = t.tracer
tc, err := client.NewClient(clientConfig)
if err != nil {
return nil, trace.BadParameter("failed to create client: %v", err)
}
return tc, nil
}
// writeError displays an error in the terminal window.
func (t *commandHandler) writeError(err error) {
out := &outEnvelope{
NodeID: t.sessionData.ServerID,
Type: envelopeTypeError,
Payload: []byte(err.Error()),
}
data, err := json.Marshal(out)
if err != nil {
t.log.WithError(err).Error("failed to marshal error message")
return
}
if _, writeErr := t.stream.Write(data); writeErr != nil {
t.log.WithError(writeErr).Warnf("Unable to send error to terminal: %v", err)
}
}