mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-29 17:58:15 +08:00
* Cache yubikey objects. * Cache PIV connections to share across the program execution. * Do not release the connection until `sign` returns * Do not ignore errors * Perform a "warm up" call to YubiKey * Fix tests * Use a specific interface to check if the key can be "warmed up" * Allow abandoning `signer.Sign` call when context is canceled * Make sure that the cached key is valid for the given private key policy The reason for adding this check was failing `invalid key policies` test. * Make `hardwareKeyWarmer` private * Force callers to release connection * Improve comments * Fix lint * Improve `connect` comment * Fix race condition * Simplify `release` logic * Trigger license/cla --------- Co-authored-by: joerger <bjoerger@goteleport.com>
463 lines
16 KiB
Go
463 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"
|
|
"encoding/asn1"
|
|
"net"
|
|
"slices"
|
|
"sync/atomic"
|
|
"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"
|
|
"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/utils/grpc/interceptors"
|
|
)
|
|
|
|
// ClientConfig contains configuration needed for a Client
|
|
// to be able to connect to the cluster.
|
|
type ClientConfig struct {
|
|
// ProxyAddress is the address of the Proxy server.
|
|
ProxyAddress string
|
|
// TLSRoutingEnabled indicates if the cluster is using TLS Routing.
|
|
TLSRoutingEnabled bool
|
|
// TLSConfigFunc produces the [tls.Config] required for mTLS connections to a specific cluster.
|
|
TLSConfigFunc func(cluster string) (*tls.Config, error)
|
|
// 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
|
|
// 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
|
|
// ALPNConnUpgradeRequired indicates that ALPN connection upgrades are
|
|
// required for making TLS routing requests.
|
|
ALPNConnUpgradeRequired bool
|
|
// InsecureSkipVerify is an option to skip HTTPS cert check
|
|
InsecureSkipVerify bool
|
|
// ViaJumpHost indicates if the connection to the cluster is direct
|
|
// or via another cluster.
|
|
ViaJumpHost bool
|
|
// PROXYHeaderGetter is used if present to get signed PROXY headers to propagate client's IP.
|
|
// Used by proxy's web server to make calls on behalf of connected clients.
|
|
PROXYHeaderGetter client.PROXYHeaderGetter
|
|
|
|
// 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(cluster string) (credentials.TransportCredentials, error)
|
|
// The client credentials to use when establishing the connection to auth.
|
|
clientCreds func(cluster string) (client.Credentials, error)
|
|
}
|
|
|
|
// CheckAndSetDefaults ensures required options are present and
|
|
// sets the default value of any that are omitted.
|
|
func (c *ClientConfig) CheckAndSetDefaults(ctx context.Context) error {
|
|
if c.ProxyAddress == "" {
|
|
return trace.BadParameter("missing required parameter ProxyAddress")
|
|
}
|
|
if c.SSHConfig == nil {
|
|
return trace.BadParameter("missing required parameter SSHConfig")
|
|
}
|
|
if c.DialTimeout <= 0 {
|
|
c.DialTimeout = defaults.DefaultIOTimeout
|
|
}
|
|
if c.TLSConfigFunc != nil {
|
|
c.clientCreds = func(cluster string) (client.Credentials, error) {
|
|
cfg, err := c.TLSConfigFunc(cluster)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return client.LoadTLS(cfg), nil
|
|
}
|
|
c.creds = func(cluster string) (credentials.TransportCredentials, error) {
|
|
tlsCfg, err := c.TLSConfigFunc(cluster)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
if !slices.Contains(tlsCfg.NextProtos, protocolProxySSHGRPC) {
|
|
tlsCfg.NextProtos = append(tlsCfg.NextProtos, protocolProxySSHGRPC)
|
|
}
|
|
|
|
// This logic still appears to be necessary to force client to always send
|
|
// a certificate regardless of the server setting. Otherwise the client may pick
|
|
// not to send the client certificate by looking at certificate request.
|
|
if len(tlsCfg.Certificates) > 0 {
|
|
cert := tlsCfg.Certificates[0]
|
|
|
|
// When a hardware key is used to store the private key, the user may fail to provide
|
|
// a PIN or touch before a gRPC dial timeout occurs.
|
|
// The resulting "dial timeout" error is generic and doesn't indicate an issue with the
|
|
// hardware key itself (since YubiKey is treated like any other key).
|
|
// To avoid this, we perform a "warm-up" call to the key, ensuring it is ready
|
|
// before initiating the gRPC dial.
|
|
// This approach works because the connection is cached for a few seconds,
|
|
// allowing subsequent calls without requiring additional user action.
|
|
if priv, ok := cert.PrivateKey.(hardwareKeyWarmer); ok {
|
|
err := priv.WarmupHardwareKey(ctx)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
}
|
|
tlsCfg.Certificates = nil
|
|
tlsCfg.GetClientCertificate = func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
|
return &cert, nil
|
|
}
|
|
}
|
|
|
|
return credentials.NewTLS(tlsCfg), nil
|
|
}
|
|
} else {
|
|
c.clientCreds = func(cluster string) (client.Credentials, error) {
|
|
return insecureCredentials{}, nil
|
|
}
|
|
c.creds = func(cluster string) (credentials.TransportCredentials, error) {
|
|
return insecure.NewCredentials(), nil
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// insecureCredentials implements [client.Credentials] and is used by tests
|
|
// to connect to the Auth server without mTLS.
|
|
type insecureCredentials struct{}
|
|
|
|
func (mc insecureCredentials) TLSConfig() (*tls.Config, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (mc insecureCredentials) SSHClientConfig() (*ssh.ClientConfig, error) {
|
|
return nil, trace.NotImplemented("no ssh config")
|
|
}
|
|
|
|
// Expiry returns the credential expiry. insecureCredentials never expire.
|
|
func (mc insecureCredentials) Expiry() (time.Time, bool) {
|
|
return time.Time{}, true
|
|
}
|
|
|
|
// 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
|
|
// clusterName as determined by inspecting the certificate presented by
|
|
// the Proxy during the connection handshake.
|
|
clusterName *clusterName
|
|
}
|
|
|
|
// 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(ctx); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clt, err := newGRPCClient(ctx, &cfg)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// If connecting via a jump host make a call to perform the
|
|
// TLS handshake to ensure that we get the name of the cluster
|
|
// being connected to from its certificate.
|
|
if cfg.ViaJumpHost {
|
|
if _, err := clt.ClusterDetails(ctx); err != nil {
|
|
return nil, trace.NewAggregate(err, clt.Close())
|
|
}
|
|
}
|
|
return clt, trace.Wrap(err)
|
|
}
|
|
|
|
// clusterName stores the name of the cluster
|
|
// in a protected manner which allows it to
|
|
// be set during handshakes with the server.
|
|
type clusterName struct {
|
|
name atomic.Pointer[string]
|
|
}
|
|
|
|
func (c *clusterName) get() string {
|
|
name := c.name.Load()
|
|
if name != nil {
|
|
return *name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *clusterName) set(name string) {
|
|
c.name.CompareAndSwap(nil, &name)
|
|
}
|
|
|
|
// clusterCredentials is a [credentials.TransportCredentials] implementation
|
|
// that obtains the name of the cluster being connected to from the certificate
|
|
// presented by the server. This allows the client to determine the cluster name when
|
|
// connecting via jump hosts.
|
|
type clusterCredentials struct {
|
|
credentials.TransportCredentials
|
|
clusterName *clusterName
|
|
}
|
|
|
|
// teleportClusterASN1ExtensionOID is an extension ID used when encoding/decoding
|
|
// origin teleport cluster name into certificates.
|
|
var teleportClusterASN1ExtensionOID = asn1.ObjectIdentifier{1, 3, 9999, 1, 7}
|
|
|
|
// ClientHandshake performs the handshake with the wrapped [credentials.TransportCredentials] and
|
|
// then inspects the provided cert for the [teleportClusterASN1ExtensionOID] to determine
|
|
// the cluster that the server belongs to.
|
|
func (c *clusterCredentials) ClientHandshake(ctx context.Context, authority string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
|
conn, info, err := c.TransportCredentials.ClientHandshake(ctx, authority, conn)
|
|
if err != nil {
|
|
return conn, info, trace.Wrap(err)
|
|
}
|
|
|
|
tlsInfo, ok := info.(credentials.TLSInfo)
|
|
if !ok {
|
|
return conn, info, nil
|
|
}
|
|
|
|
certs := tlsInfo.State.PeerCertificates
|
|
if len(certs) == 0 {
|
|
return conn, info, nil
|
|
}
|
|
|
|
clientCert := certs[0]
|
|
for _, attr := range clientCert.Subject.Names {
|
|
if attr.Type.Equal(teleportClusterASN1ExtensionOID) {
|
|
val, ok := attr.Value.(string)
|
|
if ok {
|
|
c.clusterName.set(val)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return conn, info, nil
|
|
}
|
|
|
|
// 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()
|
|
|
|
c := &clusterName{}
|
|
|
|
creds, err := cfg.creds("")
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
conn, err := grpc.DialContext(
|
|
dialCtx,
|
|
cfg.ProxyAddress,
|
|
append([]grpc.DialOption{
|
|
grpc.WithContextDialer(newDialerForGRPCClient(ctx, cfg)),
|
|
grpc.WithTransportCredentials(&clusterCredentials{TransportCredentials: creds, clusterName: c}),
|
|
grpc.WithChainUnaryInterceptor(
|
|
append(cfg.UnaryInterceptors,
|
|
//nolint:staticcheck // SA1019. There is a data race in the stats.Handler that is replacing
|
|
// the interceptor. See https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4576.
|
|
otelgrpc.UnaryClientInterceptor(),
|
|
metadata.UnaryClientInterceptor,
|
|
interceptors.GRPCClientUnaryErrorInterceptor,
|
|
)...,
|
|
),
|
|
grpc.WithChainStreamInterceptor(
|
|
append(cfg.StreamInterceptors,
|
|
//nolint:staticcheck // SA1019. There is a data race in the stats.Handler that is replacing
|
|
// the interceptor. See https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4576.
|
|
otelgrpc.StreamClientInterceptor(),
|
|
metadata.StreamClientInterceptor,
|
|
interceptors.GRPCClientStreamErrorInterceptor,
|
|
)...,
|
|
),
|
|
}, cfg.DialOpts...)...,
|
|
)
|
|
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,
|
|
clusterName: c,
|
|
}, nil
|
|
}
|
|
|
|
func newDialerForGRPCClient(ctx context.Context, cfg *ClientConfig) func(context.Context, string) (net.Conn, error) {
|
|
return client.GRPCContextDialer(client.NewDialer(ctx, defaults.DefaultIdleTimeout, cfg.DialTimeout,
|
|
client.WithInsecureSkipVerify(cfg.InsecureSkipVerify),
|
|
client.WithALPNConnUpgrade(cfg.ALPNConnUpgradeRequired),
|
|
client.WithALPNConnUpgradePing(true), // Use Ping protocol for long-lived connections.
|
|
client.WithPROXYHeaderGetter(cfg.PROXYHeaderGetter),
|
|
))
|
|
}
|
|
|
|
// ClusterName returns the name of the cluster that the
|
|
// connected Proxy is a member of.
|
|
func (c *Client) ClusterName() string {
|
|
return c.clusterName.get()
|
|
}
|
|
|
|
// Close attempts to close both the gRPC and SSH connections.
|
|
func (c *Client) Close() error {
|
|
return trace.Wrap(c.grpcConn.Close())
|
|
}
|
|
|
|
// 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, error) {
|
|
creds, err := c.cfg.clientCreds(cluster)
|
|
if err != nil {
|
|
return client.Config{}, trace.Wrap(err)
|
|
}
|
|
|
|
if c.cfg.TLSRoutingEnabled {
|
|
return client.Config{
|
|
Context: ctx,
|
|
Addrs: []string{c.cfg.ProxyAddress},
|
|
Credentials: []client.Credentials{creds},
|
|
ALPNSNIAuthDialClusterName: cluster,
|
|
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
|
|
ALPNConnUpgradeRequired: c.cfg.ALPNConnUpgradeRequired,
|
|
DialOpts: c.cfg.DialOpts,
|
|
InsecureAddressDiscovery: c.cfg.InsecureSkipVerify,
|
|
DialInBackground: true,
|
|
}, nil
|
|
}
|
|
|
|
return client.Config{
|
|
Context: ctx,
|
|
Credentials: []client.Credentials{creds},
|
|
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
|
|
DialInBackground: true,
|
|
InsecureAddressDiscovery: c.cfg.InsecureSkipVerify,
|
|
Dialer: client.ContextDialerFunc(func(dialCtx context.Context, _ string, _ string) (net.Conn, error) {
|
|
conn, err := c.transport.DialCluster(dialCtx, cluster, nil)
|
|
return conn, trace.Wrap(err)
|
|
}),
|
|
DialOpts: c.cfg.DialOpts,
|
|
}, nil
|
|
}
|
|
|
|
// 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) {
|
|
conn, details, err := c.transport.DialHost(ctx, target, cluster, nil, keyring)
|
|
if err != nil {
|
|
return nil, ClusterDetails{}, trace.ConnectionProblem(err, "failed connecting to host %s: %v", target, err)
|
|
}
|
|
|
|
return conn, ClusterDetails{FIPS: details.FipsEnabled}, nil
|
|
}
|
|
|
|
// ClusterDetails retrieves cluster information as seen by the Proxy.
|
|
func (c *Client) ClusterDetails(ctx context.Context) (ClusterDetails, error) {
|
|
details, err := c.transport.ClusterDetails(ctx)
|
|
if err != nil {
|
|
return ClusterDetails{}, trace.Wrap(err)
|
|
}
|
|
|
|
return ClusterDetails{FIPS: details.FipsEnabled}, nil
|
|
}
|
|
|
|
// Ping measures the round trip latency of sending a message to the Proxy.
|
|
func (c *Client) Ping(ctx context.Context) error {
|
|
// TODO(tross): Update to call Ping when it is added to the transport service.
|
|
// For now we don't really care what method is used we just want to measure
|
|
// how long it takes to get a reply.
|
|
_, _ = c.transport.ClusterDetails(ctx)
|
|
return nil
|
|
}
|
|
|
|
// hardwareKeyWarmer performs a bogus call to the hardware key,
|
|
// to proactively prompt the user for a PIN/touch (if needed).
|
|
type hardwareKeyWarmer interface {
|
|
WarmupHardwareKey(ctx context.Context) error
|
|
}
|