fix connecting to agentless leaf nodes (#25206)

* fix connecting to agentless leaf nodes

Pass user and role information to Auth server signing OpenSSH cert
so avoid user/role lookup errors if the target node is on a leaf
cluster. Also use role and trait information from the current
connection rather than the backend to support role impersonation.

* make protobuf changes backwards-compatible
This commit is contained in:
Andrew LeFevre
2023-05-02 17:39:47 +00:00
committed by GitHub
parent 0d885e231e
commit 68feffbf82
17 changed files with 1507 additions and 1089 deletions
File diff suppressed because it is too large Load Diff
@@ -198,8 +198,8 @@ message HostCertsRequest {
// OpenSSHCertRequest specifies certificate-generation parameters
// for a certificates used to connect to Agentless nodes.
message OpenSSHCertRequest {
// Username is the Teleport username.
string Username = 1 [(gogoproto.jsontag) = "username"];
reserved 1; // Username, jsontag "username"
reserved "Username";
// PublicKey is the public key to sign.
bytes PublicKey = 2 [(gogoproto.jsontag) = "public_key"];
// TTL is the duration the certificate will be valid for.
@@ -207,8 +207,13 @@ message OpenSSHCertRequest {
(gogoproto.jsontag) = "ttl",
(gogoproto.casttype) = "Duration"
];
// Cluster is the Teleport cluster name.
// Cluster is the Teleport cluster name the target node is connected to.
string Cluster = 4 [(gogoproto.jsontag) = "cluster"];
// User is the Teleport user the certificate will be generated for.
types.UserV2 User = 5 [(gogoproto.jsontag) = "user"];
// Roles are the roles of the Teleport user the certificate will be
// generated for.
repeated types.RoleV6 Roles = 6 [(gogoproto.jsontag) = "roles"];
}
// OpenSSHCert is a SSH certificate signed by OpenSSH CA.
+12
View File
@@ -21,6 +21,8 @@ import (
"time"
"github.com/gravitational/trace"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/protoadapt"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/utils"
@@ -450,6 +452,16 @@ func (u *UserV2) ResetLocks() {
u.Spec.Status.RecoveryAttemptLockExpires = time.Time{}
}
// DeepCopy creates a clone of this user value.
func (u *UserV2) DeepCopy() User {
// github.com/golang/protobuf/proto.Clone panics when trying to
// copy a map[K]V where the type of V is a slice of anything
// other than byte. See https://github.com/gogo/protobuf/issues/14
uV2 := protoadapt.MessageV2Of(u)
uV2Copy := proto.Clone(uV2)
return protoadapt.MessageV1Of(uV2Copy).(*UserV2)
}
// IsEmpty returns true if there's no info about who created this user
func (c CreatedBy) IsEmpty() bool {
return c.User.Name == ""
+258 -90
View File
@@ -179,6 +179,7 @@ func TestIntegrations(t *testing.T) {
t.Run("EscapeSequenceTriggers", suite.bind(testEscapeSequenceTriggers))
t.Run("AuthLocalNodeControlStream", suite.bind(testAuthLocalNodeControlStream))
t.Run("AgentlessConnection", suite.bind(testAgentlessConnection))
t.Run("LeafAgentlessConnection", suite.bind(testTrustedClusterAgentless))
}
// testDifferentPinnedIP tests connection is rejected when source IP doesn't match the pinned one
@@ -3488,6 +3489,157 @@ func testTrustedTunnelNode(t *testing.T, suite *integrationTestSuite) {
require.NoError(t, aux.StopAll())
}
func testTrustedClusterAgentless(t *testing.T, suite *integrationTestSuite) {
ctx := context.Background()
username := suite.Me.Username
clusterMain := "cluster-main"
clusterAux := "cluster-aux"
mainCfg := helpers.InstanceConfig{
ClusterName: clusterMain,
HostID: helpers.HostID,
NodeName: Host,
Priv: suite.Priv,
Pub: suite.Pub,
Log: suite.Log,
}
mainCfg.Listeners = standardPortsOrMuxSetup(t, false, &mainCfg.Fds)
main := helpers.NewInstance(t, mainCfg)
aux := suite.newNamedTeleportInstance(t, clusterAux)
// main cluster has a local user and belongs to role "main-devs" and "main-admins"
mainDevs := "main-devs"
devsRole, err := types.NewRole(mainDevs, types.RoleSpecV6{
Options: types.RoleOptions{
ForwardAgent: types.NewBool(true),
},
Allow: types.RoleConditions{
Logins: []string{username},
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
// If the test is using labels, the cluster will be labeled
// and user will be granted access if labels match.
// Otherwise, to preserve backwards-compatibility
// roles with no labels will grant access to clusters with no labels.
devsRole.SetClusterLabels(types.Allow, types.Labels{"access": []string{"prod"}})
require.NoError(t, err)
mainAdmins := "main-admins"
adminsRole, err := types.NewRole(mainAdmins, types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{"superuser"},
},
})
require.NoError(t, err)
main.AddUserWithRole(username, devsRole, adminsRole)
// for role mapping test we turn on Web API on the main cluster
// as it's used
makeConfig := func(enableSSH bool) (*testing.T, []*helpers.InstanceSecrets, *servicecfg.Config) {
tconf := suite.defaultServiceConfig()
tconf.Proxy.DisableWebService = false
tconf.Proxy.DisableWebInterface = true
tconf.SSH.Enabled = enableSSH
return t, nil, tconf
}
lib.SetInsecureDevMode(true)
defer lib.SetInsecureDevMode(false)
require.NoError(t, main.CreateEx(makeConfig(false)))
require.NoError(t, aux.CreateEx(makeConfig(false)))
// auxiliary cluster has only a role aux-devs
// connect aux cluster to main cluster
// using trusted clusters, so remote user will be allowed to assume
// role specified by mapping remote role "devs" to local role "local-devs"
auxDevs := "aux-devs"
auxRole, err := types.NewRole(auxDevs, types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{username},
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
require.NoError(t, err)
err = aux.Process.GetAuthServer().UpsertRole(ctx, auxRole)
require.NoError(t, err)
trustedClusterToken := "trusted-cluster-token"
tokenResource, err := types.NewProvisionToken(trustedClusterToken, []types.SystemRole{types.RoleTrustedCluster}, time.Time{})
require.NoError(t, err)
meta := tokenResource.GetMetadata()
meta.Labels = map[string]string{"access": "prod"}
tokenResource.SetMetadata(meta)
err = main.Process.GetAuthServer().UpsertToken(ctx, tokenResource)
require.NoError(t, err)
// Note that the mapping omits admins role, this is to cover the scenario
// when root cluster and leaf clusters have different role sets
trustedCluster := main.AsTrustedCluster(trustedClusterToken, types.RoleMap{
{Remote: mainDevs, Local: []string{auxDevs}},
})
// modify trusted cluster resource name, so it would not
// match the cluster name to check that it does not matter
trustedCluster.SetName(main.Secrets.SiteName + "-cluster")
require.NoError(t, main.Start())
require.NoError(t, aux.Start())
// create user in backend
err = main.Process.GetAuthServer().UpsertRole(ctx, devsRole)
require.NoError(t, err)
err = main.Process.GetAuthServer().UpsertRole(ctx, adminsRole)
require.NoError(t, err)
err = main.Process.GetAuthServer().UpsertUser(&types.UserV2{
Kind: types.KindUser,
Metadata: types.Metadata{
Name: username,
},
Spec: types.UserSpecV2{
Roles: []string{mainDevs, mainAdmins},
},
})
require.NoError(t, err)
err = trustedCluster.CheckAndSetDefaults()
require.NoError(t, err)
// try and upsert a trusted cluster
helpers.TryCreateTrustedCluster(t, aux.Process.GetAuthServer(), trustedCluster)
helpers.WaitForTunnelConnections(t, main.Process.GetAuthServer(), clusterAux, 1)
// Wait for both cluster to see each other via reverse tunnels.
require.Eventually(t, helpers.WaitForClusters(main.Tunnel, 1), 10*time.Second, 1*time.Second,
"Two clusters do not see each other: tunnels are not working.")
require.Eventually(t, helpers.WaitForClusters(aux.Tunnel, 1), 10*time.Second, 1*time.Second,
"Two clusters do not see each other: tunnels are not working.")
// create agentless node in leaf cluster
node := createAgentlessNode(t, aux.Process.GetAuthServer(), clusterAux, "leaf-agentless-node")
// connect to leaf agentless node
creds, err := helpers.GenerateUserCreds(helpers.UserCredsRequest{
Process: main.Process,
Username: username,
RouteToCluster: clusterAux,
})
require.NoError(t, err)
tc, err := main.NewClientWithCreds(helpers.ClientConfig{
Login: username,
Cluster: clusterAux,
Host: aux.InstanceListeners.ReverseTunnel,
}, *creds)
require.NoError(t, err)
testAgentlessConn(t, tc, node)
// Stop clusters and remaining nodes.
require.NoError(t, main.StopAll())
require.NoError(t, aux.StopAll())
}
// TestDiscoveryRecovers ensures that discovery protocol recovers from a bad discovery
// state (all known proxies are offline).
func testDiscoveryRecovers(t *testing.T, suite *integrationTestSuite) {
@@ -7438,11 +7590,25 @@ func testAgentlessConnection(t *testing.T, suite *integrationTestSuite) {
})
// get OpenSSH CA public key and create host certs
ctx := context.Background()
authClient := teleInst.Process.GetAuthServer()
openSSHCA, err := authClient.GetCertAuthority(ctx, types.CertAuthID{
node := createAgentlessNode(t, authClient, helpers.Site, "agentless-node")
// create client
tc, err := teleInst.NewClient(helpers.ClientConfig{
Login: suite.Me.Username,
Cluster: helpers.Site,
Host: Host,
})
require.NoError(t, err)
testAgentlessConn(t, tc, node)
}
func createAgentlessNode(t *testing.T, authServer *auth.Server, clusterName, nodeHostname string) *types.ServerV2 {
ctx := context.Background()
openSSHCA, err := authServer.GetCertAuthority(ctx, types.CertAuthID{
Type: types.OpenSSHCA,
DomainName: helpers.Site,
DomainName: clusterName,
}, false)
require.NoError(t, err)
@@ -7452,14 +7618,14 @@ func testAgentlessConnection(t *testing.T, suite *integrationTestSuite) {
key, err := native.GeneratePrivateKey()
require.NoError(t, err)
const nodeName string = "agentless-node"
hostCertBytes, err := authClient.GenerateHostCert(
nodeUUID := uuid.New().String()
hostCertBytes, err := authServer.GenerateHostCert(
ctx,
key.MarshalSSHPublicKey(),
"",
"",
[]string{nodeName, Loopback},
helpers.Site,
[]string{nodeUUID, nodeHostname, Loopback},
clusterName,
types.RoleNode,
0,
)
@@ -7476,24 +7642,25 @@ func testAgentlessConnection(t *testing.T, suite *integrationTestSuite) {
sshAddr := startSSHServer(t, caCheckers, hostKeySigner)
// create node resource
_, err = authClient.UpsertNode(ctx, &types.ServerV2{
node := &types.ServerV2{
Kind: types.KindNode,
SubKind: types.SubKindOpenSSHNode,
Version: types.V2,
Metadata: types.Metadata{
Name: nodeName,
Name: nodeUUID,
},
Spec: types.ServerSpecV2{
Addr: sshAddr,
Hostname: nodeName,
Hostname: nodeHostname,
},
})
}
_, err = authServer.UpsertNode(ctx, node)
require.NoError(t, err)
// wait for node resource to be written to the backend
timedCtx, cancel := context.WithTimeout(ctx, time.Second)
t.Cleanup(cancel)
w, err := authClient.NewWatcher(timedCtx, types.Watch{
w, err := authServer.NewWatcher(timedCtx, types.Watch{
Name: "node-create watcher",
Kinds: []types.WatchKind{
{
@@ -7515,83 +7682,7 @@ func testAgentlessConnection(t *testing.T, suite *integrationTestSuite) {
}
require.NoError(t, w.Close())
// create client
tc, err := teleInst.NewClient(helpers.ClientConfig{
Login: suite.Me.Username,
Cluster: helpers.Site,
Host: Host,
})
require.NoError(t, err)
// connect to cluster
clt, err := tc.ConnectToCluster(ctx)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, clt.Close())
})
// connect to node
nodeClient, err := tc.ConnectToNode(
ctx,
clt,
client.NodeDetails{
Addr: sshAddr,
Namespace: tc.Namespace,
Cluster: helpers.Site,
},
tc.Username,
)
require.NoError(t, err)
t.Cleanup(func() {
// ignore the error here, nodeClient is closed below if the
// test passes
_ = nodeClient.Close()
})
// forward SSH agent
sshClient := nodeClient.Client.Client
session, err := sshClient.NewSession()
require.NoError(t, err)
t.Cleanup(func() {
// the SSH server will close the session to avoid a deadlock,
// so closing it here will result in io.EOF if the test passes
_ = session.Close()
})
// this is essentially what agent.ForwardToAgent does, but we're
// doing it manually so can take ownership of the opened SSH channel
// and check that it's closed correctly
channels := sshClient.HandleChannelOpen("auth-agent@openssh.com")
require.NotNil(t, channels)
doneServing := make(chan error)
go func() {
for ch := range channels {
channel, reqs, err := ch.Accept()
assert.NoError(t, err)
go ssh.DiscardRequests(reqs)
go func() {
doneServing <- agent.ServeAgent(tc.LocalAgent(), channel)
channel.Close()
}()
}
}()
require.NoError(t, agent.RequestAgentForwarding(session))
// run a command
err = session.Run("cmd")
require.NoError(t, err)
// test that SSH agent channel is closed properly
select {
case err := <-doneServing:
require.ErrorIs(t, err, io.EOF)
case <-time.After(3 * time.Second):
require.Fail(t, "timeout waiting for SSH agent channel to be closed")
}
require.NoError(t, nodeClient.Close())
return node
}
// startSSHServer starts a SSH server that roughly mimics an unregistered
@@ -7599,7 +7690,7 @@ func testAgentlessConnection(t *testing.T, suite *integrationTestSuite) {
// subset of SSH requests necessary for testing.
func startSSHServer(t *testing.T, caPubKeys []ssh.PublicKey, hostKey ssh.Signer) string {
sshCfg := ssh.ServerConfig{
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
cert, ok := key.(*ssh.Certificate)
if !ok {
return nil, fmt.Errorf("expected *ssh.Certificate, got %T", key)
@@ -7673,6 +7764,83 @@ func startSSHServer(t *testing.T, caPubKeys []ssh.PublicKey, hostKey ssh.Signer)
return lis.Addr().String()
}
func testAgentlessConn(t *testing.T, tc *client.TeleportClient, node *types.ServerV2) {
// connect to cluster
ctx := context.Background()
clt, err := tc.ConnectToCluster(ctx)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, clt.Close())
})
// connect to node
_, port, err := net.SplitHostPort(node.Spec.Addr)
require.NoError(t, err)
uuidAddr := net.JoinHostPort(node.Metadata.Name, port)
nodeClient, err := tc.ConnectToNode(
ctx,
clt,
client.NodeDetails{
Addr: uuidAddr,
Namespace: tc.Namespace,
Cluster: tc.SiteName,
},
tc.Username,
)
require.NoError(t, err)
t.Cleanup(func() {
// ignore the error here, nodeClient is closed below if the
// test passes
_ = nodeClient.Close()
})
// forward SSH agent
sshClient := nodeClient.Client.Client
session, err := sshClient.NewSession()
require.NoError(t, err)
t.Cleanup(func() {
// the SSH server will close the session to avoid a deadlock,
// so closing it here will result in io.EOF if the test passes
_ = session.Close()
})
// this is essentially what agent.ForwardToAgent does, but we're
// doing it manually so can take ownership of the opened SSH channel
// and check that it's closed correctly
channels := sshClient.HandleChannelOpen("auth-agent@openssh.com")
require.NotNil(t, channels)
doneServing := make(chan error)
go func() {
for ch := range channels {
channel, reqs, err := ch.Accept()
assert.NoError(t, err)
go ssh.DiscardRequests(reqs)
go func() {
doneServing <- agent.ServeAgent(tc.LocalAgent(), channel)
channel.Close()
}()
}
}()
require.NoError(t, agent.RequestAgentForwarding(session))
// run a command
err = session.Run("cmd")
require.NoError(t, err)
// test that SSH agent channel is closed properly
select {
case err := <-doneServing:
require.ErrorIs(t, err, io.EOF)
case <-time.After(3 * time.Second):
require.Fail(t, "timeout waiting for SSH agent channel to be closed")
}
require.NoError(t, nodeClient.Close())
}
// TestProxySSHPortMultiplexing ensures that the Proxy SSH port
// is serving both SSH and gRPC regardless of TLS Routing mode.
func TestProxySSHPortMultiplexing(t *testing.T) {
+127 -18
View File
@@ -26,45 +26,153 @@ import (
"golang.org/x/crypto/ssh"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/utils/sshutils"
"github.com/gravitational/teleport/lib/auth/native"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/services"
)
// CertGenerator generates certificates from a certificate request.
// AuthProvider is a subset of the full Auth API that must be connected
// to the root cluster.
type AuthProvider interface {
GetUser(username string, withSecrets bool) (types.User, error)
GetRole(ctx context.Context, name string) (types.Role, error)
}
// CertGenerator generates certificates from a certificate request. It must
// be connected to the same cluster as the target node that this certificate
// will be generated to authenticate to.
type CertGenerator interface {
GenerateOpenSSHCert(ctx context.Context, req *proto.OpenSSHCertRequest) (*proto.OpenSSHCert, error)
}
// SignerCreator returns an [ssh.Signer] that can be used to authenticate
// with an agentless node.
type SignerCreator func(ctx context.Context, certGen CertGenerator) (ssh.Signer, error)
// SignerFromSSHCertificate returns a function that attempts to
// create a [ssh.Signer] for the Identity in the provided [ssh.Certificate]
// that is signed with the OpenSSH CA and can be used to authenticate to agentless nodes.
func SignerFromSSHCertificate(certificate *ssh.Certificate, teleportUser, clusterName string, generator CertGenerator) func(context.Context) (ssh.Signer, error) {
return func(ctx context.Context) (ssh.Signer, error) {
validBefore := time.Unix(int64(certificate.ValidBefore), 0)
ttl := time.Until(validBefore)
// authClient must be connected to the root cluster, and the CertGenerator
// passed into the returned function must be connected to the same cluster
// as the target node.
func SignerFromSSHCertificate(cert *ssh.Certificate, authClient AuthProvider, clusterName, teleportUser string) SignerCreator {
return func(ctx context.Context, certGen CertGenerator) (ssh.Signer, error) {
u, err := authClient.GetUser(teleportUser, false)
if err != nil {
return nil, trace.Wrap(err)
}
user, ok := u.(*types.UserV2)
if !ok {
return nil, trace.BadParameter("unsupported user type %T", u)
}
signer, err := createAuthSigner(ctx, teleportUser, clusterName, ttl, generator)
return signer, trace.Wrap(err)
// set the user's roles and traits so impersonation will work correctly
roleNames, err := services.ExtractRolesFromCert(cert)
if err != nil {
return nil, trace.Wrap(err)
}
traits, err := services.ExtractTraitsFromCert(cert)
if err != nil {
return nil, trace.Wrap(err)
}
user.SetRoles(roleNames)
user.SetTraits(traits)
// fetch local roles so if the certificate is generated on a leaf
// cluster it won't have to lookup unknown roles
roles, err := getRoles(ctx, authClient, roleNames)
if err != nil {
return nil, trace.Wrap(err)
}
validBefore := time.Unix(int64(cert.ValidBefore), 0)
ttl := time.Until(validBefore)
params := certParams{
clusterName: clusterName,
teleportUser: user,
roles: roles,
ttl: ttl,
}
signer, err := createAuthSigner(ctx, params, certGen)
if err != nil {
return nil, trace.Wrap(err)
}
return signer, nil
}
}
// SignerFromAuthzContext returns a function that attempts to
// create a [ssh.Signer] for the [tlsca.Identity] in the provided [authz.Context]
// that is signed with the OpenSSH CA and can be used to authenticate to agentless nodes.
func SignerFromAuthzContext(authzCtx *authz.Context, generator CertGenerator) func(context.Context) (ssh.Signer, error) {
return func(ctx context.Context) (ssh.Signer, error) {
identity := authzCtx.Identity.GetIdentity()
ttl := time.Until(identity.Expires)
// authClient must be connected to the root cluster, and the CertGenerator
// passed into the returned function must be connected to the same cluster
// as the target node.
func SignerFromAuthzContext(authzCtx *authz.Context, authClient AuthProvider, clusterName string) SignerCreator {
return func(ctx context.Context, certGen CertGenerator) (ssh.Signer, error) {
u, ok := authzCtx.User.(*types.UserV2)
if !ok {
return nil, trace.BadParameter("unsupported user type %T", u)
}
// copy the user to avoid changing it in authzCtx
user := u.DeepCopy().(*types.UserV2)
signer, err := createAuthSigner(ctx, authzCtx.User.GetName(), identity.TeleportCluster, ttl, generator)
return signer, trace.Wrap(err)
// set the user's roles and traits so impersonation will work correctly
identity := authzCtx.Identity.GetIdentity()
user.SetRoles(identity.Groups)
user.SetTraits(identity.Traits)
// fetch local roles so if the certificate is generated on a leaf
// cluster it won't have to lookup unknown roles
roles, err := getRoles(ctx, authClient, identity.Groups)
if err != nil {
return nil, trace.Wrap(err)
}
params := certParams{
clusterName: clusterName,
teleportUser: user,
roles: roles,
ttl: time.Until(identity.Expires),
}
signer, err := createAuthSigner(ctx, params, certGen)
if err != nil {
return nil, trace.Wrap(err)
}
return signer, nil
}
}
func getRoles(ctx context.Context, authClient AuthProvider, roleNames []string) ([]*types.RoleV6, error) {
roles := make([]*types.RoleV6, len(roleNames))
for i, roleName := range roleNames {
r, err := authClient.GetRole(ctx, roleName)
if err != nil {
return nil, trace.Wrap(err)
}
role, ok := r.(*types.RoleV6)
if !ok {
return nil, trace.BadParameter("unsupported role type %T", r)
}
roles[i] = role
}
return roles, nil
}
type certParams struct {
clusterName string
teleportUser *types.UserV2
roles []*types.RoleV6
ttl time.Duration
}
// createAuthSigner creates a [ssh.Signer] that is signed with
// OpenSSH CA and can be used to authenticate to agentless nodes.
func createAuthSigner(ctx context.Context, username, clusterName string, ttl time.Duration, generator CertGenerator) (ssh.Signer, error) {
func createAuthSigner(ctx context.Context, params certParams, certGen CertGenerator) (ssh.Signer, error) {
// generate a new key pair
priv, err := native.GeneratePrivateKey()
if err != nil {
@@ -72,11 +180,12 @@ func createAuthSigner(ctx context.Context, username, clusterName string, ttl tim
}
// sign new public key with OpenSSH CA
reply, err := generator.GenerateOpenSSHCert(ctx, &proto.OpenSSHCertRequest{
Username: username,
reply, err := certGen.GenerateOpenSSHCert(ctx, &proto.OpenSSHCertRequest{
User: params.teleportUser,
Roles: params.roles,
PublicKey: priv.MarshalSSHPublicKey(),
TTL: proto.Duration(ttl),
Cluster: clusterName,
TTL: proto.Duration(params.ttl),
Cluster: params.clusterName,
})
if err != nil {
return nil, trace.Wrap(err)
+16 -18
View File
@@ -1448,8 +1448,8 @@ func certRequestDeviceExtensions(ext tlsca.DeviceExtensions) certRequestOption {
}
func (a *Server) GenerateOpenSSHCert(ctx context.Context, req *proto.OpenSSHCertRequest) (*proto.OpenSSHCert, error) {
if req.Username == "" {
return nil, trace.BadParameter("username is empty")
if req.User == nil {
return nil, trace.BadParameter("user is empty")
}
if len(req.PublicKey) == 0 {
return nil, trace.BadParameter("public key is empty")
@@ -1464,29 +1464,27 @@ func (a *Server) GenerateOpenSSHCert(ctx context.Context, req *proto.OpenSSHCert
return nil, trace.BadParameter("cluster is empty")
}
user, err := a.GetUser(req.Username, false)
if err != nil {
return nil, trace.Wrap(err)
// add implicit roles to the set and build a checker
accessInfo := services.AccessInfoFromUser(req.User)
roles := make([]types.Role, len(req.Roles))
for i := range req.Roles {
roles[i] = services.ApplyTraits(req.Roles[i], req.User.GetTraits())
}
accessInfo := services.AccessInfoFromUser(user)
roleSet := services.NewRoleSet(roles...)
clusterName, err := a.GetClusterName()
if err != nil {
return nil, trace.Wrap(err)
}
checker, err := services.NewAccessChecker(accessInfo, clusterName.GetClusterName(), a)
if err != nil {
return nil, trace.Wrap(err)
}
checker := services.NewAccessCheckerWithRoleSet(accessInfo, clusterName.GetClusterName(), roleSet)
certs, err := a.generateOpenSSHCert(certRequest{
user: user,
publicKey: req.PublicKey,
compatibility: constants.CertificateFormatStandard,
checker: checker,
ttl: time.Duration(req.TTL),
traits: map[string][]string{
constants.TraitLogins: {req.Username},
},
user: req.User,
publicKey: req.PublicKey,
compatibility: constants.CertificateFormatStandard,
checker: checker,
ttl: time.Duration(req.TTL),
traits: req.User.GetTraits(),
routeToCluster: req.Cluster,
disallowReissue: true,
})
+13 -2
View File
@@ -1896,14 +1896,21 @@ func TestGenerateOpenSSHCert(t *testing.T) {
require.NoError(t, err)
// create keypair and sign with OpenSSH CA
user, _, err := CreateUserAndRole(p.a, "test-user", []string{}, nil)
logins := []string{"login1", "login2"}
u, r, err := CreateUserAndRole(p.a, "test-user", logins, nil)
require.NoError(t, err)
user, ok := u.(*types.UserV2)
require.True(t, ok)
role, ok := r.(*types.RoleV6)
require.True(t, ok)
priv, err := native.GeneratePrivateKey()
require.NoError(t, err)
reply, err := p.a.GenerateOpenSSHCert(ctx, &proto.OpenSSHCertRequest{
Username: user.GetName(),
User: user,
Roles: []*types.RoleV6{role},
PublicKey: priv.MarshalSSHPublicKey(),
TTL: proto.Duration(time.Hour),
Cluster: p.clusterName.GetClusterName(),
@@ -1930,6 +1937,10 @@ func TestGenerateOpenSSHCert(t *testing.T) {
require.NoError(t, err)
require.Equal(t, caPubkey.Marshal(), signedCert.SignatureKey.Marshal())
// verify that user's logins are present in cert
logins = append(logins, teleport.SSHSessionJoinPrincipal)
require.Equal(t, logins, signedCert.ValidPrincipals)
}
func TestGenerateUserCertWithLocks(t *testing.T) {
+1 -1
View File
@@ -736,7 +736,7 @@ func (a *ServerWithRoles) AuthenticateSSHUser(ctx context.Context, req Authentic
// to connect to Agentless nodes.
func (a *ServerWithRoles) GenerateOpenSSHCert(ctx context.Context, req *proto.OpenSSHCertRequest) (*proto.OpenSSHCert, error) {
// this limits the requests types to proxies to make it harder to break
if !a.hasBuiltinRole(types.RoleProxy) {
if !a.hasBuiltinRole(types.RoleProxy) && !a.hasRemoteBuiltinRole(string(types.RoleRemoteProxy)) {
return nil, trace.AccessDenied("this request can be only executed by a proxy")
}
return a.authServer.GenerateOpenSSHCert(ctx, req)
+7 -2
View File
@@ -33,6 +33,7 @@ import (
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/observability/tracing"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/agentless"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/observability/metrics"
@@ -196,7 +197,7 @@ func NewRouter(cfg RouterConfig) (*Router, error) {
// configuration is not set to route to the most recent an error is returned. Also returns teleport version of the
// target server if it's a teleport server
// DELETE IN 14.0: remove returning teleport version, it was needed for compatibility
func (r *Router) DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.Addr, host, port, clusterName string, accessChecker services.AccessChecker, agentGetter teleagent.Getter, signer func(context.Context) (ssh.Signer, error)) (_ net.Conn, teleportVersion string, err error) {
func (r *Router) DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.Addr, host, port, clusterName string, accessChecker services.AccessChecker, agentGetter teleagent.Getter, signer agentless.SignerCreator) (_ net.Conn, teleportVersion string, err error) {
ctx, span := r.tracer.Start(
ctx,
"router/DialHost",
@@ -277,7 +278,11 @@ func (r *Router) DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.
// when connecting to the remote node
var sshSigner ssh.Signer
if isAgentlessNode {
sshSigner, err = signer(ctx)
client, err := r.GetSiteClient(ctx, clusterName)
if err != nil {
return nil, "", trace.Wrap(err)
}
sshSigner, err = signer(ctx, client)
if err != nil {
return nil, "", trace.Wrap(err)
}
+16 -1
View File
@@ -26,6 +26,8 @@ import (
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/agentless"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/auth/native"
"github.com/gravitational/teleport/lib/observability/tracing"
"github.com/gravitational/teleport/lib/reversetunnel"
@@ -324,6 +326,18 @@ func (r testRemoteSite) DialAuthServer(reversetunnel.DialParams) (net.Conn, erro
return r.conn, r.err
}
func (r testRemoteSite) GetClient() (auth.ClientI, error) {
return nil, nil
}
type testSiteGetter struct {
site reversetunnel.RemoteSite
}
func (s testSiteGetter) GetSite(clusterName string) (reversetunnel.RemoteSite, error) {
return s.site, nil
}
type fakeConn struct {
net.Conn
}
@@ -360,7 +374,7 @@ func TestRouter_DialHost(t *testing.T) {
agentGetter := func() (teleagent.Agent, error) {
return nil, nil
}
createSigner := func(context.Context) (ssh.Signer, error) {
createSigner := func(_ context.Context, _ agentless.CertGenerator) (ssh.Signer, error) {
key, err := native.GeneratePrivateKey()
if err != nil {
return nil, err
@@ -438,6 +452,7 @@ func TestRouter_DialHost(t *testing.T) {
clusterName: "test",
log: logger,
localSite: &testRemoteSite{conn: fakeConn{}},
siteGetter: &testSiteGetter{site: &testRemoteSite{conn: fakeConn{}}},
tracer: tracing.NoopTracer("test"),
serverResolver: serverResolver(agentlessSrv, nil),
},
+2 -2
View File
@@ -3973,8 +3973,8 @@ func (process *TeleportProcess) initProxyEndpoint(conn *Connector) error {
FIPS: cfg.FIPS,
Logger: process.log.WithField(trace.Component, "transport"),
Dialer: proxyRouter,
SignerFn: func(authzCtx *authz.Context) func(context.Context) (ssh.Signer, error) {
return agentless.SignerFromAuthzContext(authzCtx, conn.Client)
SignerFn: func(authzCtx *authz.Context, clusterName string) agentless.SignerCreator {
return agentless.SignerFromAuthzContext(authzCtx, accessPoint, clusterName)
},
ConnectionMonitor: connMonitor,
LocalAddr: listeners.sshGRPC.Addr(),
+4 -4
View File
@@ -259,14 +259,14 @@ func (t *proxySubsys) proxyToSite(ctx context.Context, ch ssh.Channel, clusterNa
func (t *proxySubsys) proxyToHost(ctx context.Context, ch ssh.Channel, clientSrcAddr, clientDstAddr net.Addr) error {
t.log.Debugf("proxy connecting to host=%v port=%v, exact port=%v", t.host, t.port, t.SpecifiedPort())
aGetter := t.ctx.StartAgentChannel
client, err := t.router.GetSiteClient(ctx, t.clusterName)
authClient, err := t.router.GetSiteClient(ctx, t.localCluster)
if err != nil {
return trace.Wrap(err)
}
identity := t.ctx.Identity
signer := agentless.SignerFromSSHCertificate(t.ctx.Identity.Certificate, t.ctx.Identity.TeleportUser, t.clusterName, client)
signer := agentless.SignerFromSSHCertificate(identity.Certificate, authClient, t.clusterName, identity.TeleportUser)
aGetter := t.ctx.StartAgentChannel
conn, teleportVersion, err := t.router.DialHost(ctx, clientSrcAddr, clientDstAddr, t.host, t.port, t.clusterName, t.ctx.Identity.AccessChecker, aGetter, signer)
if err != nil {
return trace.Wrap(err)
+6 -5
View File
@@ -23,13 +23,13 @@ import (
"github.com/gravitational/trace"
"github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
transportv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/transport/v1"
streamutils "github.com/gravitational/teleport/api/utils/grpc/stream"
"github.com/gravitational/teleport/lib/agentless"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/services"
@@ -40,10 +40,10 @@ import (
// Dialer is the interface that groups basic dialing methods.
type Dialer interface {
DialSite(ctx context.Context, cluster string, clientSrcAddr, clientDstAddr net.Addr) (net.Conn, error)
DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.Addr, host, port, cluster string, checker services.AccessChecker, agentGetter teleagent.Getter, singer func(context.Context) (ssh.Signer, error)) (_ net.Conn, teleportVersion string, err error)
DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.Addr, host, port, cluster string, checker services.AccessChecker, agentGetter teleagent.Getter, singer agentless.SignerCreator) (_ net.Conn, teleportVersion string, err error)
}
// ConnMonitor monitors authorized connnections and terminates them when
// ConnMonitor monitors authorized connections and terminates them when
// session controls dictate so.
type ConnectionMonitor interface {
MonitorConn(ctx context.Context, authCtx *authz.Context, conn net.Conn) (context.Context, error)
@@ -59,7 +59,7 @@ type ServerConfig struct {
// Dialer is used to establish remote connections.
Dialer Dialer
// SignerFn is used to create an [ssh.Signer] for an authenticated connection.
SignerFn func(authzCtx *authz.Context) func(context.Context) (ssh.Signer, error)
SignerFn func(authzCtx *authz.Context, clusterName string) agentless.SignerCreator
// ConnectionMonitor is used to monitor the connection for activity and terminate it
// when conditions are met.
ConnectionMonitor ConnectionMonitor
@@ -277,7 +277,8 @@ func (s *Service) ProxySSH(stream transportv1pb.TransportService_ProxySSHServer)
if err != nil {
return trace.Wrap(err, "could get not client destination address; listener address %q, client source address %q", s.cfg.LocalAddr.String(), p.Addr.String())
}
signer := s.cfg.SignerFn(authzContext)
signer := s.cfg.SignerFn(authzContext, req.DialTarget.Cluster)
hostConn, _, err := s.cfg.Dialer.DialHost(ctx, p.Addr, clientDst, host, port, req.DialTarget.Cluster, authzContext.Checker, s.cfg.agentGetterFn(agentStreamRW), signer)
if err != nil {
return trace.Wrap(err, "failed to dial target host")
@@ -41,6 +41,7 @@ import (
transportv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/transport/v1"
streamutils "github.com/gravitational/teleport/api/utils/grpc/stream"
"github.com/gravitational/teleport/lib/agentless"
"github.com/gravitational/teleport/lib/authz"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/teleagent"
@@ -109,7 +110,7 @@ func (f fakeDialer) DialSite(ctx context.Context, clusterName string, clientSrcA
return conn, nil
}
func (f fakeDialer) DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.Addr, host, port, cluster string, checker services.AccessChecker, agentGetter teleagent.Getter, singer func(context.Context) (ssh.Signer, error)) (_ net.Conn, teleportVersion string, err error) {
func (f fakeDialer) DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.Addr, host, port, cluster string, checker services.AccessChecker, agentGetter teleagent.Getter, singer agentless.SignerCreator) (_ net.Conn, teleportVersion string, err error) {
key := fmt.Sprintf("%s.%s.%s", host, port, cluster)
conn, ok := f.hostConns[key]
if !ok {
@@ -218,8 +219,8 @@ func newServer(t *testing.T, cfg ServerConfig) testPack {
}
}
func fakeSigner(authzCtx *authz.Context) func(context.Context) (ssh.Signer, error) {
return func(context.Context) (ssh.Signer, error) {
func fakeSigner(authzCtx *authz.Context, clusterName string) agentless.SignerCreator {
return func(_ context.Context, _ agentless.CertGenerator) (ssh.Signer, error) {
return nil, nil
}
}
@@ -282,7 +283,7 @@ func TestService_ProxyCluster(t *testing.T) {
fn: func(t *testing.T, stream transportv1pb.TransportService_ProxyClusterClient, conn *echoConn) {
require.NoError(t, stream.Send(&transportv1pb.ProxyClusterRequest{Cluster: cluster}))
var msg = []byte("hello")
msg := []byte("hello")
require.NoError(t, stream.Send(&transportv1pb.ProxyClusterRequest{Frame: &transportv1pb.Frame{Payload: msg}}))
resp, err := stream.Recv()
@@ -300,7 +301,7 @@ func TestService_ProxyCluster(t *testing.T) {
require.NoError(t, stream.Send(&transportv1pb.ProxyClusterRequest{Cluster: cluster}))
require.NoError(t, conn.Close())
var msg = []byte("hello")
msg := []byte("hello")
require.NoError(t, stream.Send(&transportv1pb.ProxyClusterRequest{Frame: &transportv1pb.Frame{Payload: msg}}))
resp, err := stream.Recv()
@@ -447,7 +448,7 @@ func TestService_ProxySSH_Errors(t *testing.T) {
require.Nil(t, resp.Frame)
require.NoError(t, conn.Close())
var msg = []byte("hello")
msg := []byte("hello")
require.NoError(t, stream.Send(&transportv1pb.ProxySSHRequest{Frame: &transportv1pb.ProxySSHRequest_Ssh{Ssh: &transportv1pb.Frame{Payload: msg}}}))
resp, err = stream.Recv()
@@ -504,7 +505,6 @@ func TestService_ProxySSH_Errors(t *testing.T) {
require.NoError(t, err)
test.fn(t, stream, conn)
})
}
}
@@ -648,7 +648,7 @@ func TestService_ProxySSH(t *testing.T) {
// send an ssh request to our server which will echo the payload
// back in the response.
var msg = []byte("hello")
msg := []byte("hello")
ok, response, err := client.SendRequest("echo", true, msg)
require.NoError(t, err)
require.True(t, ok)
@@ -777,7 +777,7 @@ func (s *sshServer) DialSite(ctx context.Context, clusterName string, clientSrcA
// nil and is of type testAgent, then the server will serve its keyring
// over the underlying [streamutils.ReadWriter] so that tests can exercise
// ssh agent multiplexing.
func (s *sshServer) DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.Addr, host, port, cluster string, checker services.AccessChecker, agentGetter teleagent.Getter, singer func(context.Context) (ssh.Signer, error)) (_ net.Conn, teleportVersion string, err error) {
func (s *sshServer) DialHost(ctx context.Context, clientSrcAddr, clientDstAddr net.Addr, host, port, cluster string, checker services.AccessChecker, agentGetter teleagent.Getter, singer agentless.SignerCreator) (_ net.Conn, teleportVersion string, err error) {
conn, err := s.dial()
if err != nil {
return nil, "", trace.Wrap(err)
+7 -3
View File
@@ -2427,7 +2427,8 @@ func (h *Handler) getClusterLocks(
r *http.Request,
p httprouter.Params,
sessionCtx *SessionContext,
site reversetunnel.RemoteSite) (interface{}, error) {
site reversetunnel.RemoteSite,
) (interface{}, error) {
ctx := r.Context()
clt, err := sessionCtx.GetUserClient(ctx, site)
if err != nil {
@@ -2453,7 +2454,8 @@ func (h *Handler) createClusterLock(
r *http.Request,
p httprouter.Params,
sessionCtx *SessionContext,
site reversetunnel.RemoteSite) (interface{}, error) {
site reversetunnel.RemoteSite,
) (interface{}, error) {
var req *createLockReq
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
@@ -2500,7 +2502,8 @@ func (h *Handler) deleteClusterLock(
r *http.Request,
p httprouter.Params,
sessionCtx *SessionContext,
site reversetunnel.RemoteSite) (interface{}, error) {
site reversetunnel.RemoteSite,
) (interface{}, error) {
ctx := r.Context()
clt, err := sessionCtx.GetUserClient(ctx, site)
if err != nil {
@@ -2605,6 +2608,7 @@ func (h *Handler) siteNodeConnect(
Term: req.Term,
SessionCtx: sessionCtx,
AuthProvider: clt,
LocalAuthProvider: h.auth.accessPoint,
DisplayLogin: displayLogin,
SessionData: sessionData,
KeepAliveInterval: netConfig.GetKeepAliveInterval(),
+9
View File
@@ -1212,6 +1212,7 @@ func TestNewTerminalHandler(t *testing.T) {
AuthProvider: authProviderMock{
server: validNode,
},
LocalAuthProvider: authProviderMock{},
SessionData: session.Session{
ID: session.NewID(),
Login: "root",
@@ -6766,6 +6767,14 @@ func (mock authProviderMock) GenerateOpenSSHCert(ctx context.Context, req *authp
return nil, nil
}
func (mock authProviderMock) GetUser(_ string, _ bool) (types.User, error) {
return nil, nil
}
func (mock authProviderMock) GetRole(_ context.Context, _ string) (types.Role, error) {
return nil, nil
}
type terminalOpt func(t *TerminalRequest)
func withSessionID(sid session.ID) terminalOpt {
+24 -17
View File
@@ -116,6 +116,7 @@ func NewTerminal(ctx context.Context, cfg TerminalHandlerConfig) (*TerminalHandl
}),
ctx: cfg.SessionCtx,
authProvider: cfg.AuthProvider,
localAuthProvider: cfg.LocalAuthProvider,
displayLogin: cfg.DisplayLogin,
sessionData: cfg.SessionData,
keepAliveInterval: cfg.KeepAliveInterval,
@@ -132,29 +133,32 @@ func NewTerminal(ctx context.Context, cfg TerminalHandlerConfig) (*TerminalHandl
// TerminalHandlerConfig contains the configuration options necessary to
// correctly setup the TerminalHandler
type TerminalHandlerConfig struct {
// term is the initial PTY size.
// Term is the initial PTY size.
Term session.TerminalParams
// sctx is the context for the users web session.
// SessionCtx is the context for the users web session.
SessionCtx *SessionContext
// authProvider is used to fetch nodes and sessions from the backend.
// AuthProvider is used to fetch nodes and sessions from the backend.
AuthProvider AuthProvider
// displayLogin is the login name to display in the UI.
// LocalAuthProvider is used to fetch user information from the
// local cluster when connecting to agentless nodes.
LocalAuthProvider agentless.AuthProvider
// DisplayLogin is the login name to display in the UI.
DisplayLogin string
// sessionData is the data to send to the client on the initial session creation.
// 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 web client.
// KeepAliveInterval is the interval for sending ping frames to 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 is the address of the server to connect to.
ProxyHostPort string
// interactiveCommand is a command to execute.
// 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
// ProxySigner is used to sign PROXY header and securely propagate client IP information
// PROXYSigner is used to sign PROXY header and securely propagate client IP information
PROXYSigner multiplexer.PROXYHeaderSigner
// tracer is used to create spans
tracer oteltrace.Tracer
@@ -186,6 +190,10 @@ func (t *TerminalHandlerConfig) CheckAndSetDefaults() error {
return trace.BadParameter("AuthProvider must be provided")
}
if t.LocalAuthProvider == nil {
return trace.BadParameter("LocalAuthProvider must be provided")
}
if t.SessionCtx == nil {
return trace.BadParameter("SessionCtx must be provided")
}
@@ -227,6 +235,10 @@ type TerminalHandler struct {
// authProvider is used to fetch nodes and sessions from the backend.
authProvider AuthProvider
// localAuthProvider is used to fetch user information from the
// local cluster when connecting to agentless nodes.
localAuthProvider agentless.AuthProvider
closeOnce sync.Once
// keepAliveInterval is the interval for sending ping frames to web client.
@@ -658,12 +670,7 @@ func (t *TerminalHandler) connectToHost(ctx context.Context, ws *websocket.Conn,
if err != nil {
return nil, trace.Wrap(err)
}
authClient, err := t.router.GetSiteClient(ctx, tc.SiteName)
if err != nil {
return nil, trace.Wrap(err)
}
signer := agentless.SignerFromSSHCertificate(cert, tc.Username, tc.SiteName, authClient)
signer := agentless.SignerFromSSHCertificate(cert, t.localAuthProvider, tc.SiteName, tc.Username)
type clientRes struct {
clt *client.NodeClient
@@ -765,7 +772,7 @@ func (t *TerminalHandler) streamTerminal(ws *websocket.Conn, tc *client.Teleport
// connectToNode attempts to connect to the host with the already
// provisioned certs for the user.
func (t *TerminalHandler) connectToNode(ctx context.Context, ws *websocket.Conn, tc *client.TeleportClient, accessChecker services.AccessChecker, getAgent teleagent.Getter, signer func(context.Context) (ssh.Signer, error)) (*client.NodeClient, error) {
func (t *TerminalHandler) connectToNode(ctx context.Context, ws *websocket.Conn, tc *client.TeleportClient, accessChecker services.AccessChecker, getAgent teleagent.Getter, signer agentless.SignerCreator) (*client.NodeClient, error) {
conn, _, err := t.router.DialHost(ctx, ws.RemoteAddr(), ws.LocalAddr(), t.sessionData.ServerID, strconv.Itoa(t.sessionData.ServerHostPort), tc.SiteName, accessChecker, getAgent, signer)
if err != nil {
t.log.WithError(err).Warn("Unable to stream terminal - failed to dial host.")
@@ -797,7 +804,7 @@ func (t *TerminalHandler) connectToNode(ctx context.Context, ws *websocket.Conn,
// connectToNodeWithMFA attempts to perform the mfa ceremony and then dial the
// host with the retrieved single use certs.
func (t *TerminalHandler) connectToNodeWithMFA(ctx context.Context, ws *websocket.Conn, tc *client.TeleportClient, accessChecker services.AccessChecker, getAgent teleagent.Getter, signer func(context.Context) (ssh.Signer, error)) (*client.NodeClient, error) {
func (t *TerminalHandler) connectToNodeWithMFA(ctx context.Context, ws *websocket.Conn, tc *client.TeleportClient, accessChecker services.AccessChecker, getAgent teleagent.Getter, signer agentless.SignerCreator) (*client.NodeClient, error) {
// perform mfa ceremony and retrieve new certs
authMethods, err := t.issueSessionMFACerts(ctx, tc)
if err != nil {