From 8aac9e944145aabc01a5171e3f031c84c2b49d12 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Tue, 28 Jan 2025 16:29:00 -0800 Subject: [PATCH] [v17][vnet] remote app provider (#51566) Backport #51279 to branch/v17 --- lib/vnet/client_application_service.go | 184 ++++++++++++++++++ lib/vnet/client_application_service_client.go | 138 +++++++++++++ lib/vnet/network_stack.go | 2 +- lib/vnet/remote_app_provider.go | 136 +++++++++++++ lib/vnet/vnet_test.go | 85 +++++++- 5 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 lib/vnet/client_application_service.go create mode 100644 lib/vnet/client_application_service_client.go create mode 100644 lib/vnet/remote_app_provider.go diff --git a/lib/vnet/client_application_service.go b/lib/vnet/client_application_service.go new file mode 100644 index 0000000000000..e99ff57ac9978 --- /dev/null +++ b/lib/vnet/client_application_service.go @@ -0,0 +1,184 @@ +// Teleport +// Copyright (C) 2025 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 . + +package vnet + +import ( + "context" + "crypto" + "crypto/rand" + "sync" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api" + vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" +) + +// clientApplicationService wraps a local app provider to implement the gRPC +// [vnetv1.ClientApplicationServiceServer] to expose Teleport apps to a VNet +// service running in another process. +type clientApplicationService struct { + // opt-in to compilation errors if this doesn't implement + // [vnetv1.ClientApplicationServiceServer] + vnetv1.UnsafeClientApplicationServiceServer + + appProvider appProvider + + // mu protects appSignerCache + mu sync.Mutex + // appSignerCache caches the crypto.Signer for each certificate issued by + // ReissueAppCert so that SignForApp can later use that signer. + // + // Signers are never deleted from the map. When the cert expires, the local + // proxy in the admin process will detect the cert expiry and call + // ReissueAppCert, which will overwrite the signer for the app with a new + // one. + appSignerCache map[appKey]crypto.Signer +} + +func newClientApplicationService(appProvider appProvider) *clientApplicationService { + return &clientApplicationService{ + appProvider: appProvider, + appSignerCache: make(map[appKey]crypto.Signer), + } +} + +// Ping implements [vnetv1.ClientApplicationServiceServer.Ping]. +func (s *clientApplicationService) Ping(ctx context.Context, req *vnetv1.PingRequest) (*vnetv1.PingResponse, error) { + return &vnetv1.PingResponse{}, nil +} + +// AuthenticateProcess implements [vnetv1.ClientApplicationServiceServer.AuthenticateProcess]. +func (s *clientApplicationService) AuthenticateProcess(ctx context.Context, req *vnetv1.AuthenticateProcessRequest) (*vnetv1.AuthenticateProcessResponse, error) { + log.DebugContext(ctx, "Received AuthenticateProcess request from admin process") + if req.Version != api.Version { + return nil, trace.BadParameter("version mismatch, user process version is %s, admin process version is %s", + api.Version, req.Version) + } + // TODO(nklaassen): implement process authentication. + return &vnetv1.AuthenticateProcessResponse{ + Version: api.Version, + }, nil +} + +// ResolveAppInfo implements [vnetv1.ClientApplicationServiceServer.ResolveAppInfo]. +func (s *clientApplicationService) ResolveAppInfo(ctx context.Context, req *vnetv1.ResolveAppInfoRequest) (*vnetv1.ResolveAppInfoResponse, error) { + appInfo, err := s.appProvider.ResolveAppInfo(ctx, req.GetFqdn()) + if err != nil { + return nil, trace.Wrap(err, "resolving app info") + } + return &vnetv1.ResolveAppInfoResponse{ + AppInfo: appInfo, + }, nil +} + +// ReissueAppCert implements [vnetv1.ClientApplicationServiceServer.ReissueAppCert]. +// It caches the signer issued for each app so that it can later be used to +// issue signatures in [clientApplicationService.SignForApp]. +func (s *clientApplicationService) ReissueAppCert(ctx context.Context, req *vnetv1.ReissueAppCertRequest) (*vnetv1.ReissueAppCertResponse, error) { + if req.AppInfo == nil { + return nil, trace.BadParameter("missing AppInfo") + } + cert, err := s.appProvider.ReissueAppCert(ctx, req.GetAppInfo(), uint16(req.GetTargetPort())) + if err != nil { + return nil, trace.Wrap(err, "reissuing app certificate") + } + s.setSignerForApp(req.GetAppInfo().GetAppKey(), uint16(req.GetTargetPort()), cert.PrivateKey.(crypto.Signer)) + return &vnetv1.ReissueAppCertResponse{ + Cert: cert.Certificate[0], + }, nil +} + +// SignForApp implements [vnetv1.ClientApplicationServiceServer.SignForApp]. +// It uses a cached signer for the requested app, which must have previously +// been issued a certificate via [clientApplicationService.ReissueAppCert]. +func (s *clientApplicationService) SignForApp(ctx context.Context, req *vnetv1.SignForAppRequest) (*vnetv1.SignForAppResponse, error) { + log.DebugContext(ctx, "Got SignForApp request", + "app", req.GetAppKey(), + "hash", req.GetHash(), + "digest_len", len(req.GetDigest()), + ) + var hash crypto.Hash + switch req.GetHash() { + case vnetv1.Hash_HASH_NONE: + hash = crypto.Hash(0) + case vnetv1.Hash_HASH_SHA256: + hash = crypto.SHA256 + default: + return nil, trace.BadParameter("unsupported hash %v", req.GetHash()) + } + appKey := req.GetAppKey() + + signer, ok := s.getSignerForApp(req.GetAppKey(), uint16(req.GetTargetPort())) + if !ok { + return nil, trace.BadParameter("no signer for app %v", appKey) + } + + signature, err := signer.Sign(rand.Reader, req.GetDigest(), hash) + if err != nil { + return nil, trace.Wrap(err, "signing for app %v", appKey) + } + return &vnetv1.SignForAppResponse{ + Signature: signature, + }, nil +} + +func (s *clientApplicationService) setSignerForApp(appKey *vnetv1.AppKey, targetPort uint16, signer crypto.Signer) { + s.mu.Lock() + defer s.mu.Unlock() + s.appSignerCache[newAppKey(appKey, targetPort)] = signer +} + +func (s *clientApplicationService) getSignerForApp(appKey *vnetv1.AppKey, targetPort uint16) (crypto.Signer, bool) { + s.mu.Lock() + defer s.mu.Unlock() + signer, ok := s.appSignerCache[newAppKey(appKey, targetPort)] + return signer, ok +} + +// OnNewConnection gets called whenever a new connection is about to be +// established through VNet for observability. +func (s *clientApplicationService) OnNewConnection(ctx context.Context, req *vnetv1.OnNewConnectionRequest) (*vnetv1.OnNewConnectionResponse, error) { + if err := s.appProvider.OnNewConnection(ctx, req.GetAppKey()); err != nil { + return nil, trace.Wrap(err) + } + return &vnetv1.OnNewConnectionResponse{}, nil +} + +// OnInvalidLocalPort gets called before VNet refuses to handle a connection +// to a multi-port TCP app because the provided port does not match any of the +// TCP ports in the app spec. +func (s *clientApplicationService) OnInvalidLocalPort(ctx context.Context, req *vnetv1.OnInvalidLocalPortRequest) (*vnetv1.OnInvalidLocalPortResponse, error) { + s.appProvider.OnInvalidLocalPort(ctx, req.GetAppInfo(), uint16(req.GetTargetPort())) + return &vnetv1.OnInvalidLocalPortResponse{}, nil +} + +// appKey is a clone of [vnetv1.AppKey] that is not a protobuf type so it can be +// used as a key in maps. +type appKey struct { + profile, leafCluster, app string + port uint16 +} + +func newAppKey(protoAppKey *vnetv1.AppKey, port uint16) appKey { + return appKey{ + profile: protoAppKey.GetProfile(), + leafCluster: protoAppKey.GetLeafCluster(), + app: protoAppKey.GetName(), + port: port, + } +} diff --git a/lib/vnet/client_application_service_client.go b/lib/vnet/client_application_service_client.go new file mode 100644 index 0000000000000..7a7bdd32ad161 --- /dev/null +++ b/lib/vnet/client_application_service_client.go @@ -0,0 +1,138 @@ +// Teleport +// Copyright (C) 2025 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 . + +package vnet + +import ( + "context" + + "github.com/gravitational/trace" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/gravitational/teleport/api" + "github.com/gravitational/teleport/api/utils/grpc/interceptors" + vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" +) + +// clientApplicationServiceClient is a gRPC client for the client application +// service. This client is used in the Windows admin service to make requests to +// the VNet client application. +type clientApplicationServiceClient struct { + clt vnetv1.ClientApplicationServiceClient + conn *grpc.ClientConn +} + +func newClientApplicationServiceClient(ctx context.Context, addr string) (*clientApplicationServiceClient, error) { + conn, err := grpc.NewClient(addr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(interceptors.GRPCClientUnaryErrorInterceptor), + grpc.WithStreamInterceptor(interceptors.GRPCClientStreamErrorInterceptor), + ) + if err != nil { + return nil, trace.Wrap(err, "creating user process gRPC client") + } + return &clientApplicationServiceClient{ + clt: vnetv1.NewClientApplicationServiceClient(conn), + conn: conn, + }, nil +} + +func (c *clientApplicationServiceClient) close() error { + return trace.Wrap(c.conn.Close()) +} + +// Ping pings the client application. +func (c *clientApplicationServiceClient) Ping(ctx context.Context) error { + if _, err := c.clt.Ping(ctx, &vnetv1.PingRequest{}); err != nil { + return trace.Wrap(err, "pinging client application") + } + return nil +} + +// Authenticate process authenticates the client application process. +func (c *clientApplicationServiceClient) AuthenticateProcess(ctx context.Context, pipePath string) error { + resp, err := c.clt.AuthenticateProcess(ctx, &vnetv1.AuthenticateProcessRequest{ + Version: api.Version, + PipePath: pipePath, + }) + if err != nil { + return trace.Wrap(err, "authenticating process") + } + if resp.Version != api.Version { + return trace.BadParameter("version mismatch, user process version is %s, admin process version is %s", + resp.Version, api.Version) + } + return nil +} + +// ResolveAppInfo resolves fqdn to a [*vnetv1.AppInfo], or returns an error if +// no matching app is found. +func (c *clientApplicationServiceClient) ResolveAppInfo(ctx context.Context, fqdn string) (*vnetv1.AppInfo, error) { + resp, err := c.clt.ResolveAppInfo(ctx, &vnetv1.ResolveAppInfoRequest{ + Fqdn: fqdn, + }) + if err != nil { + return nil, trace.Wrap(err, "resolving app info") + } + return resp.GetAppInfo(), nil +} + +// ReissueAppCert issues a new certificate for the requested app. +func (c *clientApplicationServiceClient) ReissueAppCert(ctx context.Context, appInfo *vnetv1.AppInfo, targetPort uint16) ([]byte, error) { + resp, err := c.clt.ReissueAppCert(ctx, &vnetv1.ReissueAppCertRequest{ + AppInfo: appInfo, + TargetPort: uint32(targetPort), + }) + if err != nil { + return nil, trace.Wrap(err, "reissuing app cert") + } + return resp.GetCert(), nil +} + +// SignForApp returns a cryptographic signature with the key associated with the +// requested app. The key resides in the client application process. +func (c *clientApplicationServiceClient) SignForApp(ctx context.Context, req *vnetv1.SignForAppRequest) ([]byte, error) { + resp, err := c.clt.SignForApp(ctx, req) + if err != nil { + return nil, trace.Wrap(err, "signing for app") + } + return resp.GetSignature(), nil +} + +// OnNewConnection reports a new TCP connection to the target app. +func (c *clientApplicationServiceClient) OnNewConnection(ctx context.Context, appKey *vnetv1.AppKey) error { + _, err := c.clt.OnNewConnection(ctx, &vnetv1.OnNewConnectionRequest{ + AppKey: appKey, + }) + if err != nil { + return trace.Wrap(err) + } + return nil +} + +// OnInvalidLocalPort reports a failed connection to an invalid local port for +// the target app. +func (c *clientApplicationServiceClient) OnInvalidLocalPort(ctx context.Context, appInfo *vnetv1.AppInfo, targetPort uint16) error { + _, err := c.clt.OnInvalidLocalPort(ctx, &vnetv1.OnInvalidLocalPortRequest{ + AppInfo: appInfo, + TargetPort: uint32(targetPort), + }) + if err != nil { + return trace.Wrap(err) + } + return nil +} diff --git a/lib/vnet/network_stack.go b/lib/vnet/network_stack.go index e275fcab48e0a..9be591c2db5ab 100644 --- a/lib/vnet/network_stack.go +++ b/lib/vnet/network_stack.go @@ -110,7 +110,7 @@ type tcpHandlerResolver interface { // // Avoid using [trace.Wrap] on errNoTCPHandler where possible, this isn't an // unexpected error that should require the overhead of collecting a stack trace. -var errNoTCPHandler = errors.New("no handler for address") +var errNoTCPHandler = &trace.NotFoundError{Message: "no handler for address"} // tcpHandlerSpec specifies a VNet TCP handler. type tcpHandlerSpec struct { diff --git a/lib/vnet/remote_app_provider.go b/lib/vnet/remote_app_provider.go new file mode 100644 index 0000000000000..bc0a802c6867f --- /dev/null +++ b/lib/vnet/remote_app_provider.go @@ -0,0 +1,136 @@ +// Teleport +// Copyright (C) 2025 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 . + +package vnet + +import ( + "context" + "crypto" + "crypto/tls" + "crypto/x509" + "io" + + "github.com/gravitational/trace" + + vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" +) + +// remoteAppProvider implements appProvider when the client application is +// available over gRPC. +type remoteAppProvider struct { + clt *clientApplicationServiceClient +} + +func newRemoteAppProvider(clt *clientApplicationServiceClient) *remoteAppProvider { + return &remoteAppProvider{ + clt: clt, + } +} + +// ResolveAppInfo implements [appProvider.ResolveAppInfo]. +func (p *remoteAppProvider) ResolveAppInfo(ctx context.Context, fqdn string) (*vnetv1.AppInfo, error) { + appInfo, err := p.clt.ResolveAppInfo(ctx, fqdn) + return appInfo, trace.Wrap(err) +} + +// ReissueAppCert issues a new cert for the target app. Signatures made with the +// returned [tls.Certificate] happen over gRPC as the key never leaves the +// client application process. +func (p *remoteAppProvider) ReissueAppCert(ctx context.Context, appInfo *vnetv1.AppInfo, targetPort uint16) (tls.Certificate, error) { + cert, err := p.clt.ReissueAppCert(ctx, appInfo, targetPort) + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "reissuing certificate for app %s", appInfo.GetAppKey().GetName()) + } + signer, err := p.newAppCertSigner(cert, appInfo.GetAppKey(), targetPort) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + tlsCert := tls.Certificate{ + Certificate: [][]byte{cert}, + PrivateKey: signer, + } + return tlsCert, nil +} + +func (p *remoteAppProvider) newAppCertSigner(cert []byte, appKey *vnetv1.AppKey, targetPort uint16) (*rpcAppCertSigner, error) { + x509Cert, err := x509.ParseCertificate(cert) + if err != nil { + return nil, trace.Wrap(err, "parsing x509 certificate") + } + return &rpcAppCertSigner{ + clt: p.clt, + pub: x509Cert.PublicKey, + appKey: appKey, + targetPort: targetPort, + }, nil +} + +// rpcAppCertSigner implements [crypto.Signer] for app TLS signatures that are +// issued by the client application over gRPC. +type rpcAppCertSigner struct { + clt *clientApplicationServiceClient + pub crypto.PublicKey + appKey *vnetv1.AppKey + targetPort uint16 +} + +// Public implements [crypto.Signer.Public] and returns the public key +// associated with the signer. +func (s *rpcAppCertSigner) Public() crypto.PublicKey { + return s.pub +} + +// Sign implements [crypto.Signer.Sign] and issues a signature over digest for +// the associated app. +func (s *rpcAppCertSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + protoHash := vnetv1.Hash_HASH_UNSPECIFIED + switch opts.HashFunc() { + case 0: + protoHash = vnetv1.Hash_HASH_NONE + case crypto.SHA256: + protoHash = vnetv1.Hash_HASH_SHA256 + } + signature, err := s.clt.SignForApp(context.TODO(), &vnetv1.SignForAppRequest{ + AppKey: s.appKey, + TargetPort: uint32(s.targetPort), + Digest: digest, + Hash: protoHash, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return signature, nil +} + +// OnNewConnection reports a new TCP connection to the target app. +func (p *remoteAppProvider) OnNewConnection(ctx context.Context, appKey *vnetv1.AppKey) error { + if err := p.clt.OnNewConnection(ctx, appKey); err != nil { + return trace.Wrap(err) + } + return nil +} + +// OnInvalidLocalPort reports a failed connection to an invalid local port for +// the target app. +func (p *remoteAppProvider) OnInvalidLocalPort(ctx context.Context, appInfo *vnetv1.AppInfo, targetPort uint16) { + if err := p.clt.OnInvalidLocalPort(ctx, appInfo, targetPort); err != nil { + log.ErrorContext(ctx, "Could not notify client application about invalid local port", + "error", err, + "app_name", appInfo.GetAppKey().GetName(), + "target_port", targetPort, + ) + } +} diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index 72dbc0c773909..1e41f48029b39 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -28,6 +28,7 @@ import ( "fmt" "io" "log/slog" + "maps" "math/big" "net" "os" @@ -42,7 +43,8 @@ import ( "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/exp/maps" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" "gvisor.dev/gvisor/pkg/tcpip/link/channel" @@ -53,6 +55,7 @@ import ( headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/grpc/interceptors" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/cryptosuites" @@ -282,7 +285,7 @@ func newFakeClientApp( // ListProfiles lists the names of all profiles saved for the user. func (p *fakeClientApp) ListProfiles() ([]string, error) { - return maps.Keys(p.clusters), nil + return slices.Collect(maps.Keys(p.clusters)), nil } // GetCachedClient returns a [*client.ClusterClient] for the given profile and leaf cluster. @@ -841,6 +844,84 @@ func TestOnNewConnection(t *testing.T) { require.Equal(t, uint32(1), clientApp.onNewConnectionCallCount.Load()) } +// TestRemoteAppProvider tests basic VNet functionality when remoteAppProvider +// is used to provider access to the client application over gRPC. +func TestRemoteAppProvider(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + clock := clockwork.NewFakeClockAt(time.Now()) + ca := newSelfSignedCA(t) + dialOpts := mustStartFakeWebProxy(ctx, t, ca, clock) + + const appCertLifetime = time.Hour + reissueClientCert := func() tls.Certificate { + return newClientCert(t, ca, "testclient", clock.Now().Add(appCertLifetime)) + } + + clientApp := newFakeClientApp(map[string]testClusterSpec{ + "root.example.com": { + apps: []appSpec{ + appSpec{publicAddr: "echo"}, + }, + cidrRange: "192.168.2.0/24", + leafClusters: map[string]testClusterSpec{ + "leaf.example.com": { + apps: []appSpec{ + appSpec{publicAddr: "echo"}, + }, + cidrRange: "192.168.2.0/24", + }, + }, + }, + }, dialOpts, reissueClientCert, clock) + + grpcServer := grpc.NewServer( + grpc.Creds(insecure.NewCredentials()), + grpc.UnaryInterceptor(interceptors.GRPCServerUnaryErrorInterceptor), + grpc.StreamInterceptor(interceptors.GRPCServerStreamErrorInterceptor), + ) + appProvider := newLocalAppProvider(clientApp, clock) + svc := newClientApplicationService(appProvider) + vnetv1.RegisterClientApplicationServiceServer(grpcServer, svc) + listener, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + utils.RunTestBackgroundTask(ctx, t, &utils.TestBackgroundTask{ + Name: "user process gRPC server", + Task: func(ctx context.Context) error { + return trace.Wrap(grpcServer.Serve(listener), "serving VNet user process gRPC service") + }, + Terminate: func() error { + grpcServer.Stop() + return nil + }, + }) + + clt, err := newClientApplicationServiceClient(ctx, listener.Addr().String()) + require.NoError(t, err) + defer clt.close() + remoteAppProvider := newRemoteAppProvider(clt) + + p := newTestPack(t, ctx, testPackConfig{ + clock: clock, + appProvider: remoteAppProvider, + }) + + for _, app := range []string{ + "echo.root.example.com", + "echo.leaf.example.com", + } { + conn, err := p.dialHost(ctx, app, 123) + require.NoError(t, err) + testEchoConnection(t, conn) + } + dialCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + _, err = p.dialHost(dialCtx, "badapp.root.example.com.", 123) + require.Error(t, err) +} + func randomULAAddress() (tcpip.Address, error) { var bytes [16]byte bytes[0] = 0xfd