Files
teleport/api/client/proxy/client.go
T
rosstimothy 1c3188a38a Add a dedicated client to communicate with the Proxy SSH server (#22629)
A new `api/client/proxy/Client` has been added to interact with
the SSH and gRPC servers that the Proxy serves on its SSH port.
The client will first try connecting to the gRPC server and if
that fails it will fall back to the SSH server.

Much of the SSH functionality mimics the existing behavior of the
`ProxyClient` in `lib/client`. This is the first part of phasing
out that client in favor of the new client. There will be a follow
up PR that migrates `lib/client` to make use of the new client instead.

Part of #19812
2023-03-15 13:41:56 +00:00

494 lines
16 KiB
Go

// 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 proxy
import (
"context"
"crypto/tls"
"io"
"net"
"strings"
"time"
"github.com/gravitational/trace"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/exp/slices"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"github.com/gravitational/teleport/api/breaker"
"github.com/gravitational/teleport/api/client"
"github.com/gravitational/teleport/api/client/proxy/transport/transportv1"
"github.com/gravitational/teleport/api/defaults"
transportv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/transport/v1"
"github.com/gravitational/teleport/api/metadata"
"github.com/gravitational/teleport/api/observability/tracing"
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
)
// SSHDialer provides a mechanism to create a ssh client.
type SSHDialer interface {
// Dial establishes a client connection to an SSH server.
Dial(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*tracessh.Client, error)
}
// SSHDialerFunc implements SSHDialer
type SSHDialerFunc func(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*tracessh.Client, error)
// Dial calls f(ctx, network, addr, config).
func (f SSHDialerFunc) Dial(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*tracessh.Client, error) {
return f(ctx, network, addr, config)
}
// ClientConfig contains configuration needed for a Client
// to be able to connect to the cluster.
type ClientConfig struct {
// ProxyWebAddress is the address of the Proxy Web server.
ProxyWebAddress string
// ProxySSHAddress is the address of the Proxy SSH server.
ProxySSHAddress string
// TLSRoutingEnabled indicates if the cluster is using TLS Routing.
TLSRoutingEnabled bool
// ClusterName is the name of the Teleport cluster that the client
// will be connected to.
ClusterName string
// TLSConfig contains the tls.Config required for mTLS connections.
TLSConfig *tls.Config
// UnaryInterceptors are optional [grpc.UnaryClientInterceptor] to apply
// to the gRPC client.
UnaryInterceptors []grpc.UnaryClientInterceptor
// StreamInterceptors are optional [grpc.StreamClientInterceptor] to apply
// to the gRPC client.
StreamInterceptors []grpc.StreamClientInterceptor
// SSHDialer allows callers to control how a [tracessh.Client] is created.
SSHDialer SSHDialer
// SSHConfig is the [ssh.ClientConfig] used to connect to the Proxy SSH server.
SSHConfig *ssh.ClientConfig
// DialTimeout defines how long to attempt dialing before timing out.
DialTimeout time.Duration
// DialOpts define options for dialing the client connection.
DialOpts []grpc.DialOption
// The below items are intended to be used by tests to connect without mTLS.
// The gRPC transport credentials to use when establishing the connection to proxy.
creds func() credentials.TransportCredentials
// The client credentials to use when establishing the connection to auth.
clientCreds func() client.Credentials
}
func (c *ClientConfig) CheckAndSetDefaults() error {
if c.ProxyWebAddress == "" {
return trace.BadParameter("missing required parameter ProxyWebAddress")
}
if c.ProxySSHAddress == "" {
return trace.BadParameter("missing required parameter ProxySSHAddress")
}
if c.ClusterName == "" {
return trace.BadParameter("missing required parameter ClusterName")
}
if c.SSHDialer == nil {
return trace.BadParameter("missing required parameter SSHDialer")
}
if c.SSHConfig == nil {
return trace.BadParameter("missing required parameter SSHConfig")
}
if c.DialTimeout <= 0 {
c.DialTimeout = defaults.DefaultIOTimeout
}
if c.TLSConfig != nil {
if !slices.Contains(c.TLSConfig.NextProtos, protocolProxySSHGRPC) {
tlsCfg := c.TLSConfig.Clone()
tlsCfg.NextProtos = append(tlsCfg.NextProtos, protocolProxySSHGRPC)
c.TLSConfig = tlsCfg
}
c.clientCreds = func() client.Credentials {
return client.LoadTLS(c.TLSConfig.Clone())
}
c.creds = func() credentials.TransportCredentials {
return credentials.NewTLS(c.TLSConfig.Clone())
}
} else {
c.clientCreds = func() client.Credentials {
return insecureCredentials{}
}
c.creds = func() credentials.TransportCredentials {
return insecure.NewCredentials()
}
}
return nil
}
type insecureCredentials struct{}
func (mc insecureCredentials) Dialer(client.Config) (client.ContextDialer, error) {
return nil, trace.NotImplemented("no dialer")
}
func (mc insecureCredentials) TLSConfig() (*tls.Config, error) {
return nil, nil
}
func (mc insecureCredentials) SSHClientConfig() (*ssh.ClientConfig, error) {
return nil, trace.NotImplemented("no ssh config")
}
// Client is a client to the Teleport Proxy SSH server on behalf of a user.
// The Proxy SSH port used to serve only SSH, however portions of the api are
// being migrated to gRPC to reduce latency. The Client is capable of communicating
// to the Proxy via both mechanism; by default it will choose to use gRPC over
// SSH where it is able to.
type Client struct {
// cfg are the user provided configuration parameters required to
// connect and interact with the Proxy.
cfg *ClientConfig
// grpcConn is the established gRPC connection to the Proxy.
grpcConn *grpc.ClientConn
// transport is the transportv1.Client
transport *transportv1.Client
// sshClient is the established SSH connection to the Proxy.
sshClient *tracessh.Client
}
// protocolProxySSHGRPC is TLS ALPN protocol value used to indicate gRPC
// traffic intended for the Teleport Proxy on the SSH port.
const protocolProxySSHGRPC string = "teleport-proxy-ssh-grpc"
// NewClient creates a new Client that attempts to connect to the gRPC
// server being served by the Proxy SSH port by default. If unable to
// connect the Client falls back to connecting to the Proxy SSH port
// via SSH.
//
// If it is known that the gRPC server doesn't serve the required API
// of the caller, then prefer to use NewSSHClient instead which omits
// the gRPC dialing altogether.
func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) {
if err := cfg.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
clt, grpcErr := newGRPCClient(ctx, &cfg)
if grpcErr == nil {
// Attempt an RPC to ensure the proxy is serving gRPC on the
// SSH Port. This is needed for backward compatibility with
// Proxies that aren't serving gRPC since dialing happens in
// the background.
//
// DELETE IN 14.0.0
_, err := clt.transport.ClusterDetails(ctx)
if err == nil {
return clt, nil
}
}
clt, sshErr := newSSHClient(ctx, &cfg)
if sshErr == nil {
return clt, nil
}
return nil, trace.NewAggregate(grpcErr, sshErr)
}
// newGRPCClient creates a Client that is connected via gRPC.
func newGRPCClient(ctx context.Context, cfg *ClientConfig) (_ *Client, err error) {
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
defer cancel()
conn, err := grpc.DialContext(
dialCtx,
cfg.ProxySSHAddress,
append(cfg.DialOpts,
grpc.WithTransportCredentials(cfg.creds()),
grpc.WithChainUnaryInterceptor(
append(cfg.UnaryInterceptors,
otelgrpc.UnaryClientInterceptor(),
metadata.UnaryClientInterceptor,
)...,
),
grpc.WithChainStreamInterceptor(
append(cfg.StreamInterceptors,
otelgrpc.StreamClientInterceptor(),
metadata.StreamClientInterceptor,
)...,
),
)...,
)
if err != nil {
return nil, trace.Wrap(err)
}
defer func() {
if err != nil {
_ = conn.Close()
}
}()
transport, err := transportv1.NewClient(transportv1pb.NewTransportServiceClient(conn))
if err != nil {
return nil, trace.Wrap(err)
}
return &Client{
cfg: cfg,
grpcConn: conn,
transport: transport,
}, nil
}
// newSSHClient creates a Client that is connected via SSH.
func newSSHClient(ctx context.Context, cfg *ClientConfig) (*Client, error) {
clt, err := cfg.SSHDialer.Dial(ctx, "tcp", cfg.ProxySSHAddress, cfg.SSHConfig)
if err != nil {
return nil, trace.Wrap(err)
}
return &Client{
cfg: cfg,
sshClient: clt,
}, nil
}
// Close attempts to close both the gRPC and SSH connections.
func (c *Client) Close() error {
var errs []error
if c.sshClient != nil {
errs = append(errs, c.sshClient.Close())
}
if c.grpcConn != nil {
errs = append(errs, c.grpcConn.Close())
}
return trace.NewAggregate(errs...)
}
// SSHConfig returns the [ssh.ClientConfig] for the provided user which
// should be used when creating a [tracessh.Client] with the returned
// [net.Conn] from [Client.DialHost].
func (c *Client) SSHConfig(user string) *ssh.ClientConfig {
return &ssh.ClientConfig{
Config: c.cfg.SSHConfig.Config,
User: user,
Auth: c.cfg.SSHConfig.Auth,
HostKeyCallback: c.cfg.SSHConfig.HostKeyCallback,
BannerCallback: c.cfg.SSHConfig.BannerCallback,
ClientVersion: c.cfg.SSHConfig.ClientVersion,
HostKeyAlgorithms: c.cfg.SSHConfig.HostKeyAlgorithms,
Timeout: c.cfg.SSHConfig.Timeout,
}
}
// ClusterDetails provide cluster configuration
// details as known by the connected Proxy.
type ClusterDetails struct {
// FIPS dictates whether FIPS mode is enabled.
FIPS bool
}
// ClientConfig returns a [client.Config] that may be used to connect to the
// Auth server in the provided cluster via [client.New] or similar. The [client.Config]
// returned will have the correct credentials and dialer set based on the ClientConfig
// that was provided to create this Client.
func (c *Client) ClientConfig(ctx context.Context, cluster string) client.Config {
switch {
case c.cfg.TLSRoutingEnabled:
return client.Config{
Context: ctx,
Addrs: []string{c.cfg.ProxyWebAddress},
Credentials: []client.Credentials{c.cfg.clientCreds()},
ALPNSNIAuthDialClusterName: cluster,
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
}
case c.sshClient != nil:
return client.Config{
Context: ctx,
Credentials: []client.Credentials{c.cfg.clientCreds()},
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
DialInBackground: true,
Dialer: client.ContextDialerFunc(func(dialCtx context.Context, _ string, _ string) (net.Conn, error) {
// Don't dial if the context has timed out.
select {
case <-dialCtx.Done():
return nil, dialCtx.Err()
default:
}
conn, err := dialSSH(dialCtx, c.sshClient, c.cfg.ProxySSHAddress, "@"+cluster, nil)
return conn, trace.Wrap(err)
}),
}
default:
return client.Config{
Context: ctx,
Credentials: []client.Credentials{c.cfg.clientCreds()},
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
DialInBackground: true,
Dialer: client.ContextDialerFunc(func(dialCtx context.Context, _ string, _ string) (net.Conn, error) {
// Don't dial if the context has timed out.
select {
case <-dialCtx.Done():
return nil, dialCtx.Err()
default:
}
// Intentionally not using the dial context because it is only valid
// for the lifetime of the dial. Using it causes the stream to be terminated
// immediately after the dial completes.
connContext := tracing.WithPropagationContext(context.Background(), tracing.PropagationContextFromContext(dialCtx))
conn, err := c.transport.DialCluster(connContext, cluster, nil)
return conn, trace.Wrap(err)
}),
}
}
}
// DialHost establishes a connection to the `target` in cluster named `cluster`. If a keyring
// is provided it will only be forwarded if proxy recording mode is enabled in the cluster.
func (c *Client) DialHost(ctx context.Context, target, cluster string, keyring agent.ExtendedAgent) (net.Conn, ClusterDetails, error) {
if c.sshClient != nil {
conn, details, err := c.dialHostSSH(ctx, target, cluster, keyring)
return conn, details, trace.Wrap(err)
}
conn, details, err := c.transport.DialHost(ctx, target, cluster, nil, keyring)
if err != nil {
return nil, ClusterDetails{}, trace.Wrap(err)
}
return conn, ClusterDetails{FIPS: details.FipsEnabled}, nil
}
// dialHostSSH connects to the target via SSH. To match backwards compatibility the
// cluster details are retrieved from the Proxy SSH server via a clusterDetailsRequest
// request to determine if the keyring should be forwarded.
func (c *Client) dialHostSSH(ctx context.Context, target, cluster string, keyring agent.ExtendedAgent) (net.Conn, ClusterDetails, error) {
details, err := c.clusterDetailsSSH(ctx)
if err != nil {
return nil, ClusterDetails{FIPS: details.FIPSEnabled}, trace.Wrap(err)
}
// Prevent forwarding the keychain if the proxy is
// not doing the recording.
if !details.RecordingProxy {
keyring = nil
}
conn, err := dialSSH(ctx, c.sshClient, c.cfg.ProxySSHAddress, target+"@"+cluster, keyring)
return conn, ClusterDetails{FIPS: details.FIPSEnabled}, trace.Wrap(err)
}
// ClusterDetails retrieves cluster information as seen by the Proxy.
func (c *Client) ClusterDetails(ctx context.Context) (ClusterDetails, error) {
if c.sshClient != nil {
details, err := c.clusterDetailsSSH(ctx)
return ClusterDetails{FIPS: details.FIPSEnabled}, trace.Wrap(err)
}
details, err := c.transport.ClusterDetails(ctx)
if err != nil {
return ClusterDetails{}, trace.Wrap(err)
}
return ClusterDetails{FIPS: details.FipsEnabled}, nil
}
// sshDetails is the response from a clusterDetailsRequest.
type sshDetails struct {
RecordingProxy bool
FIPSEnabled bool
}
const clusterDetailsRequest = "cluster-details@goteleport.com"
// clusterDetailsSSH retrieves the cluster details via a clusterDetailsRequest.
func (c *Client) clusterDetailsSSH(ctx context.Context) (sshDetails, error) {
ok, resp, err := c.sshClient.SendRequest(ctx, clusterDetailsRequest, true, nil)
if err != nil {
return sshDetails{}, trace.Wrap(err)
}
if !ok {
return sshDetails{}, trace.ConnectionProblem(nil, "failed to get cluster details")
}
var details sshDetails
if err := ssh.Unmarshal(resp, &details); err != nil {
return sshDetails{}, trace.Wrap(err)
}
return details, trace.Wrap(err)
}
// dialSSH creates a SSH session to the target address and proxies a [net.Conn]
// over the standard input and output of the session.
func dialSSH(ctx context.Context, clt *tracessh.Client, proxyAddress, targetAddress string, keyring agent.ExtendedAgent) (_ net.Conn, err error) {
session, err := clt.NewSession(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
defer func() {
if err != nil {
_ = session.Close()
}
}()
conn, err := newSessionConn(session, proxyAddress, targetAddress)
if err != nil {
return nil, trace.Wrap(err)
}
defer func() {
if err != nil {
_ = conn.Close()
}
}()
sessionError, err := session.StderrPipe()
if err != nil {
return nil, trace.Wrap(err)
}
// If a keyring was provided then set up agent forwarding.
if keyring != nil {
// Add a handler to receive requests on the auth-agent@openssh.com channel. If there is
// already a handler it's safe to ignore the error because we only need one active handler
// to process requests.
err = agent.ForwardToAgent(clt.Client, keyring)
if err != nil && !strings.Contains(err.Error(), "agent: already have handler for") {
return nil, trace.Wrap(err)
}
err = agent.RequestAgentForwarding(session.Session)
if err != nil {
return nil, trace.Wrap(err)
}
}
if err := session.RequestSubsystem(ctx, "proxy:"+targetAddress); err != nil {
// read the stderr output from the failed SSH session and append
// it to the end of our own message:
serverErrorMsg, _ := io.ReadAll(sessionError)
return nil, trace.ConnectionProblem(err, "failed connecting to host %s: %v. %v", targetAddress, serverErrorMsg, err)
}
return conn, nil
}