mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-04 02:21:02 +08:00
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
887 lines
24 KiB
Go
887 lines
24 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/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/gravitational/trace"
|
|
"github.com/gravitational/trace/trail"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/crypto/ssh/agent"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/test/bufconn"
|
|
"google.golang.org/protobuf/testing/protocmp"
|
|
|
|
"github.com/gravitational/teleport/api/client"
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
transportv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/transport/v1"
|
|
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
|
|
"github.com/gravitational/teleport/api/utils/grpc/stream"
|
|
"github.com/gravitational/teleport/api/utils/sshutils"
|
|
)
|
|
|
|
type fakeGetClusterDetails func(context.Context, *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error)
|
|
|
|
type fakeProxySSHServer func(transportv1pb.TransportService_ProxySSHServer) error
|
|
|
|
type fakeProxyClusterServer func(transportv1pb.TransportService_ProxyClusterServer) error
|
|
|
|
// fakeTransportService is a [transportv1pb.TransportServiceServer] implementation
|
|
// that allows tests to manipulate the server side of various RPCs.
|
|
type fakeTransportService struct {
|
|
transportv1pb.UnimplementedTransportServiceServer
|
|
|
|
details fakeGetClusterDetails
|
|
ssh fakeProxySSHServer
|
|
cluster fakeProxyClusterServer
|
|
}
|
|
|
|
func (s fakeTransportService) GetClusterDetails(ctx context.Context, req *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error) {
|
|
if s.details == nil {
|
|
return s.UnimplementedTransportServiceServer.GetClusterDetails(ctx, req)
|
|
}
|
|
return s.details(ctx, req)
|
|
}
|
|
|
|
func (s fakeTransportService) ProxySSH(stream transportv1pb.TransportService_ProxySSHServer) error {
|
|
if s.ssh == nil {
|
|
return s.UnimplementedTransportServiceServer.ProxySSH(stream)
|
|
}
|
|
return s.ssh(stream)
|
|
}
|
|
|
|
func (s fakeTransportService) ProxyCluster(stream transportv1pb.TransportService_ProxyClusterServer) error {
|
|
if s.cluster == nil {
|
|
return s.UnimplementedTransportServiceServer.ProxyCluster(stream)
|
|
}
|
|
return s.cluster(stream)
|
|
}
|
|
|
|
// newGRPCServer creates a [grpc.Server] and registers the
|
|
// provided [transportv1pb.TransportServiceServer].
|
|
func newGRPCServer(t *testing.T, srv transportv1pb.TransportServiceServer) *fakeGRPCServer {
|
|
// gRPC testPack.
|
|
lis := bufconn.Listen(100)
|
|
t.Cleanup(func() { require.NoError(t, lis.Close()) })
|
|
|
|
s := grpc.NewServer()
|
|
t.Cleanup(s.Stop)
|
|
|
|
// Register service.
|
|
if srv != nil {
|
|
transportv1pb.RegisterTransportServiceServer(s, srv)
|
|
}
|
|
|
|
// Start.
|
|
go func() {
|
|
if err := s.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
|
panic(fmt.Sprintf("Serve returned err = %v", err))
|
|
}
|
|
}()
|
|
|
|
return &fakeGRPCServer{Listener: lis}
|
|
}
|
|
|
|
type fakeGRPCServer struct {
|
|
*bufconn.Listener
|
|
}
|
|
|
|
type fakeSSHServer struct {
|
|
listener net.Listener
|
|
cfg fakeSSHServerConfig
|
|
}
|
|
|
|
func (s *fakeSSHServer) run() {
|
|
for {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
sconn, chans, reqs, err := ssh.NewServerConn(conn, s.cfg.config)
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.cfg.handler(sconn, chans, reqs)
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (s *fakeSSHServer) Stop() error {
|
|
return s.listener.Close()
|
|
}
|
|
|
|
func generateSigner(t *testing.T) ssh.Signer {
|
|
private, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
|
|
block := &pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(private),
|
|
}
|
|
|
|
privatePEM := pem.EncodeToMemory(block)
|
|
signer, err := ssh.ParsePrivateKey(privatePEM)
|
|
require.NoError(t, err)
|
|
return signer
|
|
}
|
|
|
|
func (s *fakeSSHServer) clientConfig() *ssh.ClientConfig {
|
|
return &ssh.ClientConfig{
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(s.cfg.cSigner)},
|
|
HostKeyCallback: ssh.FixedHostKey(s.cfg.hSigner.PublicKey()),
|
|
}
|
|
}
|
|
|
|
func (s *fakeSSHServer) newClientConn() (ssh.Conn, <-chan ssh.NewChannel, <-chan *ssh.Request, error) {
|
|
conn, err := net.Dial("tcp", s.listener.Addr().String())
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
sconn, nc, r, err := ssh.NewClientConn(conn, "", s.clientConfig())
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
return sconn, nc, r, nil
|
|
}
|
|
|
|
type sshHandler func(*ssh.ServerConn, <-chan ssh.NewChannel, <-chan *ssh.Request)
|
|
|
|
type fakeSSHServerConfig struct {
|
|
config *ssh.ServerConfig
|
|
handler sshHandler
|
|
cSigner ssh.Signer
|
|
hSigner ssh.Signer
|
|
}
|
|
|
|
func discardHandler(conn *ssh.ServerConn, chans <-chan ssh.NewChannel, reqs <-chan *ssh.Request) {
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
go ssh.DiscardRequests(reqs)
|
|
|
|
for ch := range chans {
|
|
_ = ch.Reject(ssh.Prohibited, "discard")
|
|
}
|
|
}
|
|
|
|
func proxySubsystemHandler(details sshDetails, handleConn func(conn *ssh.ServerConn, ch ssh.Channel)) sshHandler {
|
|
return func(conn *ssh.ServerConn, channels <-chan ssh.NewChannel, requests <-chan *ssh.Request) {
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
go func() {
|
|
for req := range requests {
|
|
if req.Type == clusterDetailsRequest {
|
|
_ = req.Reply(true, ssh.Marshal(details))
|
|
}
|
|
}
|
|
}()
|
|
|
|
for nch := range channels {
|
|
if nch.ChannelType() != "session" {
|
|
_ = nch.Reject(ssh.UnknownChannelType, "unknown channel")
|
|
continue
|
|
}
|
|
|
|
ch, reqs, err := nch.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
defer func() { _ = ch.Close() }()
|
|
|
|
for req := range reqs {
|
|
ok := req.Type == "subsystem"
|
|
|
|
if req.WantReply {
|
|
_ = req.Reply(ok, nil)
|
|
}
|
|
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
handleConn(conn, ch)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
|
|
func echoHandler(details sshDetails) sshHandler {
|
|
return proxySubsystemHandler(details, func(conn *ssh.ServerConn, ch ssh.Channel) {
|
|
_, _ = io.Copy(ch, ch)
|
|
})
|
|
}
|
|
|
|
func authHandler(t *testing.T) sshHandler {
|
|
return proxySubsystemHandler(sshDetails{}, func(conn *ssh.ServerConn, ch ssh.Channel) {
|
|
auth := newFakeAuthServer(t, sshutils.NewChConn(conn, ch))
|
|
t.Cleanup(auth.Stop)
|
|
_ = auth.Serve()
|
|
})
|
|
}
|
|
|
|
type fakeAuthServer struct {
|
|
*proto.UnimplementedAuthServiceServer
|
|
listener net.Listener
|
|
srv *grpc.Server
|
|
}
|
|
|
|
func newFakeAuthServer(t *testing.T, conn net.Conn) *fakeAuthServer {
|
|
f := &fakeAuthServer{
|
|
listener: newOneShotListener(conn),
|
|
UnimplementedAuthServiceServer: &proto.UnimplementedAuthServiceServer{},
|
|
srv: grpc.NewServer(),
|
|
}
|
|
|
|
t.Cleanup(f.Stop)
|
|
proto.RegisterAuthServiceServer(f.srv, f)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeAuthServer) Ping(context.Context, *proto.PingRequest) (*proto.PingResponse, error) {
|
|
return &proto.PingResponse{
|
|
ClusterName: "test",
|
|
ServerVersion: "1.0.0",
|
|
IsBoring: true,
|
|
}, nil
|
|
}
|
|
|
|
func (f *fakeAuthServer) Serve() error {
|
|
return f.srv.Serve(f.listener)
|
|
}
|
|
|
|
func (f *fakeAuthServer) Stop() {
|
|
_ = f.listener.Close()
|
|
f.srv.Stop()
|
|
}
|
|
|
|
type oneShotListener struct {
|
|
conn net.Conn
|
|
closedCh chan struct{}
|
|
listenedCh chan struct{}
|
|
}
|
|
|
|
func newOneShotListener(conn net.Conn) oneShotListener {
|
|
return oneShotListener{
|
|
conn: conn,
|
|
closedCh: make(chan struct{}),
|
|
listenedCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (l oneShotListener) Accept() (net.Conn, error) {
|
|
select {
|
|
case <-l.listenedCh:
|
|
<-l.closedCh
|
|
return nil, net.ErrClosed
|
|
default:
|
|
close(l.listenedCh)
|
|
return l.conn, nil
|
|
}
|
|
}
|
|
|
|
func (l oneShotListener) Close() error {
|
|
select {
|
|
case <-l.closedCh:
|
|
default:
|
|
close(l.closedCh)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (l oneShotListener) Addr() net.Addr {
|
|
return addr("127.0.0.1")
|
|
}
|
|
|
|
func newSSHServer(t *testing.T, cfg fakeSSHServerConfig) *fakeSSHServer {
|
|
listener, err := net.Listen("tcp", "localhost:0")
|
|
require.NoError(t, err)
|
|
|
|
srv := &fakeSSHServer{
|
|
listener: listener,
|
|
cfg: cfg,
|
|
}
|
|
|
|
go srv.run()
|
|
|
|
t.Cleanup(func() { require.NoError(t, srv.Stop()) })
|
|
return srv
|
|
}
|
|
|
|
type fakeProxy struct {
|
|
*fakeGRPCServer
|
|
*fakeSSHServer
|
|
}
|
|
|
|
func newFakeProxy(t *testing.T, sshHandler sshHandler, transportService transportv1pb.TransportServiceServer) *fakeProxy {
|
|
cSigner := generateSigner(t)
|
|
hSigner := generateSigner(t)
|
|
|
|
sshConfig := &ssh.ServerConfig{
|
|
NoClientAuth: true,
|
|
ServerVersion: "SSH-2.0-Teleport",
|
|
}
|
|
sshConfig.AddHostKey(hSigner)
|
|
|
|
sshSrv := newSSHServer(t, fakeSSHServerConfig{
|
|
config: sshConfig,
|
|
handler: sshHandler,
|
|
cSigner: cSigner,
|
|
hSigner: hSigner,
|
|
})
|
|
grpcSrv := newGRPCServer(t, transportService)
|
|
|
|
return &fakeProxy{
|
|
fakeGRPCServer: grpcSrv,
|
|
fakeSSHServer: sshSrv,
|
|
}
|
|
}
|
|
|
|
func (f *fakeProxy) clientConfig(t *testing.T) ClientConfig {
|
|
return ClientConfig{
|
|
ProxyWebAddress: "127.0.0.1",
|
|
ProxySSHAddress: "127.0.0.1",
|
|
ClusterName: "test",
|
|
SSHDialer: SSHDialerFunc(func(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*tracessh.Client, error) {
|
|
conn, chans, reqs, err := f.fakeSSHServer.newClientConn()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
clt := &tracessh.Client{Client: ssh.NewClient(conn, chans, reqs)}
|
|
t.Cleanup(func() { _ = clt.Close() })
|
|
return clt, err
|
|
}),
|
|
SSHConfig: f.fakeSSHServer.clientConfig(),
|
|
DialOpts: []grpc.DialOption{grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
|
|
return f.fakeGRPCServer.DialContext(ctx)
|
|
})},
|
|
}
|
|
}
|
|
|
|
func withoutSSH(cfg *ClientConfig) {
|
|
cfg.SSHDialer = SSHDialerFunc(func(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*tracessh.Client, error) {
|
|
return nil, trace.NotImplemented("not implemented")
|
|
})
|
|
}
|
|
|
|
func withoutGRPC(cfg *ClientConfig) {
|
|
cfg.DialOpts = []grpc.DialOption{grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
|
|
return nil, trace.NotImplemented("not implemented")
|
|
})}
|
|
}
|
|
|
|
func TestNewClient(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
tests := []struct {
|
|
name string
|
|
cfg func(cfg *ClientConfig)
|
|
srv transportv1pb.TransportServiceServer
|
|
sshHandler sshHandler
|
|
assertion func(t *testing.T, clt *Client, err error)
|
|
}{
|
|
{
|
|
name: "no ssh server and grpc that does not implement transport",
|
|
cfg: withoutSSH,
|
|
assertion: func(t *testing.T, clt *Client, err error) {
|
|
require.Error(t, err)
|
|
require.Nil(t, clt)
|
|
},
|
|
},
|
|
{
|
|
name: "no ssh server and grpc that fails to retrieve cluster details",
|
|
cfg: withoutSSH,
|
|
srv: fakeTransportService{},
|
|
assertion: func(t *testing.T, clt *Client, err error) {
|
|
require.Error(t, err)
|
|
require.Nil(t, clt)
|
|
},
|
|
},
|
|
{
|
|
name: "no ssh server but compliant grpc server",
|
|
cfg: withoutSSH,
|
|
srv: fakeTransportService{
|
|
details: func(ctx context.Context, request *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error) {
|
|
return &transportv1pb.GetClusterDetailsResponse{Details: &transportv1pb.ClusterDetails{FipsEnabled: true}}, nil
|
|
},
|
|
},
|
|
assertion: func(t *testing.T, clt *Client, err error) {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, clt)
|
|
},
|
|
},
|
|
{
|
|
name: "no grpc server and ssh server",
|
|
cfg: withoutGRPC,
|
|
sshHandler: discardHandler,
|
|
assertion: func(t *testing.T, clt *Client, err error) {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, clt)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
proxy := newFakeProxy(t, test.sshHandler, test.srv)
|
|
cfg := proxy.clientConfig(t)
|
|
test.cfg(&cfg)
|
|
|
|
clt, err := NewClient(ctx, cfg)
|
|
if clt != nil {
|
|
t.Cleanup(func() { require.NoError(t, clt.Close()) })
|
|
}
|
|
test.assertion(t, clt, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_ClusterDetails(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
cfg func(*ClientConfig)
|
|
srv transportv1pb.TransportServiceServer
|
|
sshHandler sshHandler
|
|
assertion func(t *testing.T, details ClusterDetails, err error)
|
|
}{
|
|
{
|
|
name: "cluster details via ssh",
|
|
cfg: withoutGRPC,
|
|
sshHandler: echoHandler(sshDetails{
|
|
RecordingProxy: true,
|
|
FIPSEnabled: true,
|
|
}),
|
|
assertion: func(t *testing.T, details ClusterDetails, err error) {
|
|
require.NoError(t, err)
|
|
require.True(t, details.FIPS)
|
|
},
|
|
},
|
|
{
|
|
name: "cluster details via ssh fails",
|
|
cfg: withoutGRPC,
|
|
sshHandler: discardHandler,
|
|
assertion: func(t *testing.T, details ClusterDetails, err error) {
|
|
require.Error(t, err)
|
|
},
|
|
},
|
|
{
|
|
name: "cluster details via grpc",
|
|
cfg: withoutSSH,
|
|
srv: fakeTransportService{
|
|
details: func(ctx context.Context, request *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error) {
|
|
return &transportv1pb.GetClusterDetailsResponse{Details: &transportv1pb.ClusterDetails{FipsEnabled: false}}, nil
|
|
},
|
|
},
|
|
assertion: func(t *testing.T, details ClusterDetails, err error) {
|
|
require.NoError(t, err)
|
|
require.False(t, details.FIPS)
|
|
},
|
|
},
|
|
{
|
|
name: "cluster details via grpc fails",
|
|
cfg: withoutSSH,
|
|
srv: fakeTransportService{
|
|
details: func() func(ctx context.Context, request *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error) {
|
|
// NewClient will try to get cluster details to validate that the gRPC
|
|
// connection is established, so we need to send details on the first request.
|
|
// The second request will be from the test case that is expecting an error.
|
|
sent := &atomic.Bool{}
|
|
return func(ctx context.Context, request *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error) {
|
|
if sent.Load() {
|
|
return nil, trace.ConnectionProblem(nil, "connection closed")
|
|
}
|
|
|
|
sent.Store(true)
|
|
return &transportv1pb.GetClusterDetailsResponse{Details: &transportv1pb.ClusterDetails{FipsEnabled: false}}, nil
|
|
}
|
|
}(),
|
|
},
|
|
assertion: func(t *testing.T, details ClusterDetails, err error) {
|
|
require.Error(t, err)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
proxy := newFakeProxy(t, test.sshHandler, test.srv)
|
|
cfg := proxy.clientConfig(t)
|
|
test.cfg(&cfg)
|
|
|
|
cfg.DialOpts = append(cfg.DialOpts, grpc.WithDisableRetry())
|
|
|
|
clt, err := NewClient(ctx, cfg)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, clt.Close()) })
|
|
|
|
details, err := clt.ClusterDetails(ctx)
|
|
test.assertion(t, details, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_DialHost(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
cfg func(*ClientConfig)
|
|
srv transportv1pb.TransportServiceServer
|
|
sshHandler sshHandler
|
|
keyring agent.ExtendedAgent
|
|
assertion func(t *testing.T, conn net.Conn, details ClusterDetails, err error)
|
|
}{
|
|
{
|
|
name: "ssh connection fails",
|
|
cfg: withoutGRPC,
|
|
sshHandler: discardHandler,
|
|
assertion: func(t *testing.T, conn net.Conn, details ClusterDetails, err error) {
|
|
require.Error(t, err)
|
|
require.Nil(t, conn)
|
|
require.False(t, details.FIPS)
|
|
},
|
|
},
|
|
{
|
|
name: "ssh connection established",
|
|
cfg: withoutGRPC,
|
|
sshHandler: echoHandler(sshDetails{RecordingProxy: false, FIPSEnabled: true}),
|
|
assertion: func(t *testing.T, conn net.Conn, details ClusterDetails, err error) {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, conn)
|
|
require.True(t, details.FIPS)
|
|
|
|
// test that the server echos data back over the connection
|
|
msg := []byte("hello123")
|
|
n, err := conn.Write(msg)
|
|
require.NoError(t, err)
|
|
require.Equal(t, len(msg), n)
|
|
|
|
out := make([]byte, len(msg))
|
|
n, err = conn.Read(out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, len(msg), n)
|
|
require.Equal(t, msg, out)
|
|
|
|
require.NoError(t, conn.Close())
|
|
},
|
|
},
|
|
{
|
|
name: "grpc connection fails",
|
|
cfg: withoutSSH,
|
|
srv: fakeTransportService{
|
|
details: func(ctx context.Context, request *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error) {
|
|
return &transportv1pb.GetClusterDetailsResponse{Details: &transportv1pb.ClusterDetails{FipsEnabled: true}}, nil
|
|
},
|
|
ssh: func(server transportv1pb.TransportService_ProxySSHServer) error {
|
|
_, err := server.Recv()
|
|
if err != nil {
|
|
return trail.ToGRPC(trace.Wrap(err))
|
|
}
|
|
|
|
return trail.ToGRPC(trace.ConnectionProblem(nil, "connection closed"))
|
|
},
|
|
},
|
|
assertion: func(t *testing.T, conn net.Conn, details ClusterDetails, err error) {
|
|
require.ErrorIs(t, err, trace.ConnectionProblem(nil, "connection closed"))
|
|
require.Nil(t, conn)
|
|
require.False(t, details.FIPS)
|
|
},
|
|
},
|
|
{
|
|
name: "grpc connection established",
|
|
cfg: withoutSSH,
|
|
srv: fakeTransportService{
|
|
details: func(ctx context.Context, request *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error) {
|
|
return &transportv1pb.GetClusterDetailsResponse{Details: &transportv1pb.ClusterDetails{FipsEnabled: true}}, nil
|
|
},
|
|
ssh: func(server transportv1pb.TransportService_ProxySSHServer) error {
|
|
_, err := server.Recv()
|
|
if err != nil {
|
|
return trail.ToGRPC(trace.Wrap(err))
|
|
}
|
|
|
|
if err := server.Send(&transportv1pb.ProxySSHResponse{Details: &transportv1pb.ClusterDetails{FipsEnabled: true}}); err != nil {
|
|
return trail.ToGRPC(err)
|
|
}
|
|
|
|
req, err := server.Recv()
|
|
if err != nil {
|
|
return trail.ToGRPC(trace.Wrap(err))
|
|
}
|
|
|
|
switch f := req.Frame.(type) {
|
|
case *transportv1pb.ProxySSHRequest_Ssh:
|
|
if err := server.Send(&transportv1pb.ProxySSHResponse{
|
|
Details: nil,
|
|
Frame: &transportv1pb.ProxySSHResponse_Ssh{Ssh: &transportv1pb.Frame{Payload: f.Ssh.Payload}},
|
|
}); err != nil {
|
|
return trail.ToGRPC(trace.Wrap(err))
|
|
}
|
|
default:
|
|
return trace.BadParameter("unexpected frame type received")
|
|
}
|
|
|
|
return nil
|
|
},
|
|
},
|
|
assertion: func(t *testing.T, conn net.Conn, details ClusterDetails, err error) {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, conn)
|
|
require.True(t, details.FIPS)
|
|
|
|
// test that the server echos data back over the connection
|
|
msg := []byte("hello123")
|
|
n, err := conn.Write(msg)
|
|
require.NoError(t, err)
|
|
require.Equal(t, len(msg), n)
|
|
|
|
out := make([]byte, len(msg))
|
|
n, err = conn.Read(out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, len(msg), n)
|
|
require.Equal(t, msg, out)
|
|
|
|
require.NoError(t, conn.Close())
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
proxy := newFakeProxy(t, test.sshHandler, test.srv)
|
|
cfg := proxy.clientConfig(t)
|
|
test.cfg(&cfg)
|
|
|
|
clt, err := NewClient(ctx, cfg)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, clt.Close()) })
|
|
|
|
conn, details, err := clt.DialHost(ctx, "test", "cluster", test.keyring)
|
|
test.assertion(t, conn, details, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClient_DialCluster(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
cfg func(*ClientConfig)
|
|
authCfg func(config *client.Config)
|
|
srv transportv1pb.TransportServiceServer
|
|
sshHandler sshHandler
|
|
keyring agent.ExtendedAgent
|
|
assertion func(t *testing.T, clt *client.Client, err error)
|
|
}{
|
|
{
|
|
name: "ssh connection fails",
|
|
cfg: withoutGRPC,
|
|
authCfg: func(config *client.Config) {
|
|
config.DialTimeout = 500 * time.Millisecond // speed up dial failure
|
|
},
|
|
sshHandler: discardHandler,
|
|
assertion: func(t *testing.T, clt *client.Client, err error) {
|
|
require.Error(t, err)
|
|
require.Nil(t, clt)
|
|
},
|
|
},
|
|
{
|
|
name: "ssh connection established",
|
|
cfg: withoutGRPC,
|
|
authCfg: func(config *client.Config) {},
|
|
sshHandler: authHandler(t),
|
|
assertion: func(t *testing.T, clt *client.Client, err error) {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, clt)
|
|
|
|
expected := &proto.PingResponse{
|
|
ClusterName: "test",
|
|
ServerVersion: "1.0.0",
|
|
IsBoring: true,
|
|
}
|
|
|
|
resp, err := clt.Ping(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(expected, resp, protocmp.Transform()))
|
|
},
|
|
},
|
|
{
|
|
name: "grpc connection fails",
|
|
cfg: withoutSSH,
|
|
authCfg: func(config *client.Config) {
|
|
config.DialTimeout = 500 * time.Millisecond // speed up dial failure
|
|
},
|
|
srv: fakeTransportService{
|
|
details: func(ctx context.Context, request *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error) {
|
|
return &transportv1pb.GetClusterDetailsResponse{Details: &transportv1pb.ClusterDetails{FipsEnabled: true}}, nil
|
|
},
|
|
cluster: func(server transportv1pb.TransportService_ProxyClusterServer) error {
|
|
_, err := server.Recv()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
return trace.ConnectionProblem(nil, "connection closed")
|
|
},
|
|
},
|
|
assertion: func(t *testing.T, clt *client.Client, err error) {
|
|
require.Error(t, err)
|
|
require.Nil(t, clt)
|
|
},
|
|
},
|
|
{
|
|
name: "grpc connection established",
|
|
cfg: withoutSSH,
|
|
authCfg: func(config *client.Config) {},
|
|
srv: fakeTransportService{
|
|
details: func(ctx context.Context, request *transportv1pb.GetClusterDetailsRequest) (*transportv1pb.GetClusterDetailsResponse, error) {
|
|
return &transportv1pb.GetClusterDetailsResponse{Details: &transportv1pb.ClusterDetails{FipsEnabled: true}}, nil
|
|
},
|
|
cluster: func(server transportv1pb.TransportService_ProxyClusterServer) error {
|
|
_, err := server.Recv()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
rw, err := stream.NewReadWriter(clusterStream{stream: server})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
auth := newFakeAuthServer(t, stream.NewConn(rw, nil, nil))
|
|
err = auth.Serve()
|
|
return trace.Wrap(err)
|
|
},
|
|
},
|
|
assertion: func(t *testing.T, clt *client.Client, err error) {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, clt)
|
|
|
|
expected := &proto.PingResponse{
|
|
ClusterName: "test",
|
|
ServerVersion: "1.0.0",
|
|
IsBoring: true,
|
|
}
|
|
|
|
resp, err := clt.Ping(ctx)
|
|
require.NoError(t, err)
|
|
require.Empty(t, cmp.Diff(expected, resp, protocmp.Transform()))
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
proxy := newFakeProxy(t, test.sshHandler, test.srv)
|
|
cfg := proxy.clientConfig(t)
|
|
test.cfg(&cfg)
|
|
|
|
clt, err := NewClient(ctx, cfg)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, clt.Close()) })
|
|
|
|
authCfg := clt.ClientConfig(ctx, "cluster")
|
|
authCfg.DialOpts = []grpc.DialOption{
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
grpc.WithReturnConnectionError(),
|
|
grpc.WithDisableRetry(),
|
|
grpc.FailOnNonTempDialError(true),
|
|
}
|
|
authCfg.Credentials = []client.Credentials{insecureCredentials{}}
|
|
authCfg.DialTimeout = 3 * time.Second
|
|
|
|
test.authCfg(&authCfg)
|
|
|
|
authClt, err := client.New(ctx, authCfg)
|
|
if authClt != nil {
|
|
t.Cleanup(func() {
|
|
require.NoError(t, authClt.Close())
|
|
})
|
|
}
|
|
test.assertion(t, authClt, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// clusterStream implements the [streamutils.Source] interface
|
|
// for a [transportv1pb.TransportService_ProxyClusterServer].
|
|
type clusterStream struct {
|
|
stream transportv1pb.TransportService_ProxyClusterServer
|
|
}
|
|
|
|
func (c clusterStream) Recv() ([]byte, error) {
|
|
req, err := c.stream.Recv()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if req.Frame == nil {
|
|
return nil, trace.BadParameter("received invalid frame")
|
|
}
|
|
|
|
return req.Frame.Payload, nil
|
|
}
|
|
|
|
func (c clusterStream) Send(frame []byte) error {
|
|
return trace.Wrap(c.stream.Send(&transportv1pb.ProxyClusterResponse{Frame: &transportv1pb.Frame{Payload: frame}}))
|
|
}
|
|
|
|
func TestClient_SSHConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
proxy := newFakeProxy(t, discardHandler, fakeTransportService{})
|
|
cfg := proxy.clientConfig(t)
|
|
|
|
clt, err := NewClient(context.Background(), cfg)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, clt.Close()) })
|
|
|
|
const user = "test-user"
|
|
sshConfig := clt.SSHConfig(user)
|
|
require.NotNil(t, sshConfig)
|
|
require.Equal(t, user, sshConfig.User)
|
|
require.Empty(t, cmp.Diff(cfg.SSHConfig, sshConfig, cmpopts.IgnoreFields(ssh.ClientConfig{}, "User", "Auth", "HostKeyCallback")))
|
|
}
|