From 9e22f5e859a9555db65d666e4a2dd562f29c559c Mon Sep 17 00:00:00 2001 From: Sasha Klizhentas Date: Mon, 22 Jul 2019 16:58:38 -0700 Subject: [PATCH] Add support for ProxyJump. This commit implements #2543 In SSH terms ProxyJump is a shortcut for SSH client connecting the proxy/jumphost and requesting .port forwarding to the target node. This commit adds support for direct-tcpip request support in teleport proxy service that is an alias to the existing proxy subsystem and reuses most of the code. This commit also adds support to "route to cluster" metadata encoded in SSH certificate making it possible to have client SSH certificates to include the metadata that will cause the proxy to route the client requests to a specific cluster. `tsh ssh -J proxy:port ` is supported in a limited way: Only one jump host is supported (-J supports chaining that teleport does not utilise) and tsh will return with error in case of two jumphosts: -J a,b will not work. In case if `tsh ssh -J user@proxy` is used, it overrides the SSH proxy coming from the tsh profile and port-forwarding is used instead of the existing teleport proxy subsystem --- constants.go | 3 + integration/helpers.go | 29 ++++- integration/integration_test.go | 59 +++++++--- lib/auth/auth.go | 14 ++- lib/auth/native/native.go | 16 ++- lib/auth/testauthority/testauthority.go | 15 ++- lib/client/api.go | 25 ++++- lib/client/client.go | 67 +++++++++++ lib/services/authority.go | 4 + lib/srv/authhandlers.go | 4 +- lib/srv/ctx.go | 14 +++ lib/srv/keepalive.go | 2 +- lib/srv/regular/proxy.go | 79 ++++++++++--- lib/srv/regular/sshserver.go | 143 ++++++++++++++++++++++-- lib/utils/proxyjump.go | 58 ++++++++++ lib/utils/proxyjump_test.go | 65 +++++++++++ tool/tsh/tsh.go | 13 +++ 17 files changed, 541 insertions(+), 69 deletions(-) create mode 100644 lib/utils/proxyjump.go create mode 100644 lib/utils/proxyjump_test.go diff --git a/constants.go b/constants.go index 7ae83d7c414a3..629582f4cb604 100644 --- a/constants.go +++ b/constants.go @@ -361,6 +361,9 @@ const ( CertExtensionPermitPortForwarding = "permit-port-forwarding" // CertExtensionTeleportRoles is used to propagate teleport roles CertExtensionTeleportRoles = "teleport-roles" + // CertExtensionTeleportRouteToCluster is used to encode + // the target cluster to route to in the certificate + CertExtensionTeleportRouteToCluster = "teleport-route-to-cluster" ) const ( diff --git a/integration/helpers.go b/integration/helpers.go index 285479835aa60..146723dd83d05 100644 --- a/integration/helpers.go +++ b/integration/helpers.go @@ -405,14 +405,25 @@ func SetupUser(process *service.TeleportProcess, username string, roles []servic return nil } +// UserCredsRequest is a request to generate user creds +type UserCredsRequest struct { + // Process is a teleport process + Process *service.TeleportProcess + // Username is a user to generate certs for + Username string + // RouteToCluster is an optional cluster to route creds to + RouteToCluster string +} + // GenerateUserCreds generates key to be used by client -func GenerateUserCreds(process *service.TeleportProcess, username string) (*UserCreds, error) { +func GenerateUserCreds(req UserCredsRequest) (*UserCreds, error) { priv, pub, err := testauthority.New().GenerateKeyPair("") if err != nil { return nil, trace.Wrap(err) } - a := process.GetAuthServer() - sshCert, x509Cert, err := a.GenerateUserTestCerts(pub, username, time.Hour, teleport.CertificateFormatStandard) + a := req.Process.GetAuthServer() + sshCert, x509Cert, err := a.GenerateUserTestCerts( + pub, req.Username, time.Hour, teleport.CertificateFormatStandard, req.RouteToCluster) if err != nil { return nil, trace.Wrap(err) } @@ -592,7 +603,7 @@ func (i *TeleInstance) CreateEx(trustedSecrets []*InstanceSecrets, tconf *servic } // sign user's keys: ttl := 24 * time.Hour - user.Key.Cert, user.Key.TLSCert, err = auth.GenerateUserTestCerts(user.Key.Pub, teleUser.GetName(), ttl, teleport.CertificateFormatStandard) + user.Key.Cert, user.Key.TLSCert, err = auth.GenerateUserTestCerts(user.Key.Pub, teleUser.GetName(), ttl, teleport.CertificateFormatStandard, "") if err != nil { return err } @@ -915,6 +926,8 @@ type ClientConfig struct { // ForwardAgent controls if the client requests it's agent be forwarded to // the server. ForwardAgent bool + // JumpHost turns on jump host mode + JumpHost bool } // NewClientWithCreds creates client with credentials @@ -968,6 +981,14 @@ func (i *TeleInstance) NewUnauthenticatedClient(cfg ClientConfig) (tc *client.Te SSHProxyAddr: sshProxyAddr, } + // JumpHost turns on jump host mode + if cfg.JumpHost { + cconf.JumpHosts = []utils.JumpHost{{ + Username: cfg.Login, + Addr: *utils.MustParseAddr(sshProxyAddr), + }} + } + return client.NewClient(cconf) } diff --git a/integration/integration_test.go b/integration/integration_test.go index f0d081a015cf4..7cb7313546745 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -1477,13 +1477,32 @@ func tryCreateTrustedCluster(c *check.C, authServer *auth.AuthServer, trustedClu c.Fatalf("Timeout creating trusted cluster") } +// trustedClusterTest is a test setup for trusted clusters tests +type trustedClusterTest struct { + // multiplex sets up multiplexing of the reversetunnel SSH + // socket and the proxy's web socket + multiplex bool + // useJumpHost turns on jump host mode for the access + // to the proxy instead of the proxy command + useJumpHost bool +} + // TestTrustedClusters tests remote clusters scenarios // using trusted clusters feature func (s *IntSuite) TestTrustedClusters(c *check.C) { tr := utils.NewTracer(utils.ThisFunction()).Start() defer tr.Stop() - s.trustedClusters(c, false) + s.trustedClusters(c, trustedClusterTest{multiplex: false}) +} + +// TestJumpTrustedClusters tests remote clusters scenarios +// using trusted clusters feature using jumphost connection +func (s *IntSuite) TestJumpTrustedClusters(c *check.C) { + tr := utils.NewTracer(utils.ThisFunction()).Start() + defer tr.Stop() + + s.trustedClusters(c, trustedClusterTest{multiplex: false, useJumpHost: true}) } // TestMultiplexingTrustedClusters tests remote clusters scenarios @@ -1492,15 +1511,15 @@ func (s *IntSuite) TestMultiplexingTrustedClusters(c *check.C) { tr := utils.NewTracer(utils.ThisFunction()).Start() defer tr.Stop() - s.trustedClusters(c, true) + s.trustedClusters(c, trustedClusterTest{multiplex: true}) } -func (s *IntSuite) trustedClusters(c *check.C, multiplex bool) { +func (s *IntSuite) trustedClusters(c *check.C, test trustedClusterTest) { username := s.me.Username clusterMain := "cluster-main" clusterAux := "cluster-aux" - main := NewInstance(InstanceConfig{ClusterName: clusterMain, HostID: HostID, NodeName: Host, Ports: s.getPorts(5), Priv: s.priv, Pub: s.pub, MultiplexProxy: multiplex}) + main := NewInstance(InstanceConfig{ClusterName: clusterMain, HostID: HostID, NodeName: Host, Ports: s.getPorts(5), Priv: s.priv, Pub: s.pub, MultiplexProxy: test.multiplex}) aux := NewInstance(InstanceConfig{ClusterName: clusterAux, HostID: HostID, NodeName: Host, Ports: s.getPorts(5), Priv: s.priv, Pub: s.pub}) // main cluster has a local user and belongs to role "main-devs" @@ -1580,13 +1599,20 @@ func (s *IntSuite) trustedClusters(c *check.C, multiplex bool) { // Try and connect to a node in the Aux cluster from the Main cluster using // direct dialing. - tc, err := main.NewClient(ClientConfig{ - Login: username, - Cluster: clusterAux, - Host: Loopback, - Port: sshPort, + creds, err := GenerateUserCreds(UserCredsRequest{ + Process: main.Process, + Username: username, + RouteToCluster: clusterAux, }) c.Assert(err, check.IsNil) + tc, err := main.NewClientWithCreds(ClientConfig{ + Login: username, + Cluster: clusterAux, + Host: Loopback, + Port: sshPort, + JumpHost: test.useJumpHost, + }, *creds) + c.Assert(err, check.IsNil) output := &bytes.Buffer{} tc.Stdout = output c.Assert(err, check.IsNil) @@ -2714,7 +2740,7 @@ func (s *IntSuite) TestRotateSuccess(c *check.C) { c.Assert(err, check.IsNil) // capture credentials before reload started to simulate old client - initialCreds, err := GenerateUserCreds(svc, s.me.Username) + initialCreds, err := GenerateUserCreds(UserCredsRequest{Process: svc, Username: s.me.Username}) c.Assert(err, check.IsNil) l.Infof("Service started. Setting rotation state to %v", services.RotationPhaseUpdateClients) @@ -2775,7 +2801,7 @@ func (s *IntSuite) TestRotateSuccess(c *check.C) { c.Assert(err, check.IsNil) // new credentials will work from this phase to others - newCreds, err := GenerateUserCreds(svc, s.me.Username) + newCreds, err := GenerateUserCreds(UserCredsRequest{Process: svc, Username: s.me.Username}) c.Assert(err, check.IsNil) clt, err = t.NewClientWithCreds(cfg, *newCreds) @@ -2861,7 +2887,7 @@ func (s *IntSuite) TestRotateRollback(c *check.C) { c.Assert(err, check.IsNil) // capture credentials before reload started to simulate old client - initialCreds, err := GenerateUserCreds(svc, s.me.Username) + initialCreds, err := GenerateUserCreds(UserCredsRequest{Process: svc, Username: s.me.Username}) c.Assert(err, check.IsNil) l.Infof("Service started. Setting rotation state to %v", services.RotationPhaseInit) @@ -3030,7 +3056,10 @@ func (s *IntSuite) TestRotateTrustedClusters(c *check.C) { waitForTunnelConnections(c, svc.GetAuthServer(), aux.Secrets.SiteName, 1) // capture credentials before has reload started to simulate old client - initialCreds, err := GenerateUserCreds(svc, s.me.Username) + initialCreds, err := GenerateUserCreds(UserCredsRequest{ + Process: svc, + Username: s.me.Username, + }) c.Assert(err, check.IsNil) // credentials should work @@ -3115,7 +3144,7 @@ func (s *IntSuite) TestRotateTrustedClusters(c *check.C) { c.Assert(err, check.IsNil) // new credentials will work from this phase to others - newCreds, err := GenerateUserCreds(svc, s.me.Username) + newCreds, err := GenerateUserCreds(UserCredsRequest{Process: svc, Username: s.me.Username}) c.Assert(err, check.IsNil) clt, err = main.NewClientWithCreds(cfg, *newCreds) @@ -3491,7 +3520,7 @@ func (s *IntSuite) TestList(c *check.C) { // Create user, role, and generate credentials. err = SetupUser(t.Process, tt.inLogin, []services.Role{role}) c.Assert(err, check.IsNil) - initialCreds, err := GenerateUserCreds(t.Process, tt.inLogin) + initialCreds, err := GenerateUserCreds(UserCredsRequest{Process: t.Process, Username: tt.inLogin}) c.Assert(err, check.IsNil) // Create a Teleport client. diff --git a/lib/auth/auth.go b/lib/auth/auth.go index b574b4dd3ec4d..36f32c53b3619 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -407,7 +407,7 @@ type certRequest struct { } // GenerateUserTestCerts is used to generate user certificate, used internally for tests -func (a *AuthServer) GenerateUserTestCerts(key []byte, username string, ttl time.Duration, compatibility string) ([]byte, []byte, error) { +func (a *AuthServer) GenerateUserTestCerts(key []byte, username string, ttl time.Duration, compatibility, routeToCluster string) ([]byte, []byte, error) { user, err := a.Identity.GetUser(username) if err != nil { return nil, nil, trace.Wrap(err) @@ -417,11 +417,12 @@ func (a *AuthServer) GenerateUserTestCerts(key []byte, username string, ttl time return nil, nil, trace.Wrap(err) } certs, err := a.generateUserCert(certRequest{ - user: user, - roles: checker, - ttl: ttl, - compatibility: compatibility, - publicKey: key, + user: user, + roles: checker, + ttl: ttl, + compatibility: compatibility, + publicKey: key, + routeToCluster: routeToCluster, }) if err != nil { return nil, nil, trace.Wrap(err) @@ -501,6 +502,7 @@ func (s *AuthServer) generateUserCert(req certRequest) (*certs, error) { CertificateFormat: certificateFormat, PermitPortForwarding: req.roles.CanPortForward(), PermitAgentForwarding: req.roles.CanForwardAgents(), + RouteToCluster: req.routeToCluster, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/auth/native/native.go b/lib/auth/native/native.go index 115c18ec6b048..73dd8525b8bf3 100644 --- a/lib/auth/native/native.go +++ b/lib/auth/native/native.go @@ -268,19 +268,23 @@ func (k *Keygen) GenerateUserCert(c services.UserCertParams) ([]byte, error) { if !c.PermitPortForwarding { delete(cert.Permissions.Extensions, teleport.CertExtensionPermitPortForwarding) } - if len(c.Roles) != 0 { - // only add roles to the certificate extensions if the standard format was - // requested. we allow the option to omit this to support older versions of - // OpenSSH due to a bug in <= OpenSSH 7.1 - // https://bugzilla.mindrot.org/show_bug.cgi?id=2387 - if c.CertificateFormat == teleport.CertificateFormatStandard { + // Only add roles to the certificate extensions if the standard format was + // requested. we allow the option to omit this to support older versions of + // OpenSSH due to a bug in <= OpenSSH 7.1 + // https://bugzilla.mindrot.org/show_bug.cgi?id=2387 + if c.CertificateFormat == teleport.CertificateFormatStandard { + if len(c.Roles) != 0 { roles, err := services.MarshalCertRoles(c.Roles) if err != nil { return nil, trace.Wrap(err) } cert.Permissions.Extensions[teleport.CertExtensionTeleportRoles] = roles } + if c.RouteToCluster != "" { + cert.Permissions.Extensions[teleport.CertExtensionTeleportRouteToCluster] = c.RouteToCluster + } } + signer, err := ssh.ParsePrivateKey(c.PrivateCASigningKey) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/auth/testauthority/testauthority.go b/lib/auth/testauthority/testauthority.go index f2757b157b967..5090babbd4a2a 100644 --- a/lib/auth/testauthority/testauthority.go +++ b/lib/auth/testauthority/testauthority.go @@ -110,18 +110,21 @@ func (n *Keygen) GenerateUserCert(c services.UserCertParams) ([]byte, error) { if !c.PermitPortForwarding { delete(cert.Permissions.Extensions, teleport.CertExtensionPermitPortForwarding) } - if len(c.Roles) != 0 { - // only add roles to the certificate extensions if the standard format was - // requested. we allow the option to omit this to support older versions of - // OpenSSH due to a bug in <= OpenSSH 7.1 - // https://bugzilla.mindrot.org/show_bug.cgi?id=2387 - if c.CertificateFormat == teleport.CertificateFormatStandard { + // Only add roles to the certificate extensions if the standard format was + // requested. we allow the option to omit this to support older versions of + // OpenSSH due to a bug in <= OpenSSH 7.1 + // https://bugzilla.mindrot.org/show_bug.cgi?id=2387 + if c.CertificateFormat == teleport.CertificateFormatStandard { + if len(c.Roles) != 0 { roles, err := services.MarshalCertRoles(c.Roles) if err != nil { return nil, trace.Wrap(err) } cert.Permissions.Extensions[teleport.CertExtensionTeleportRoles] = roles } + if c.RouteToCluster != "" { + cert.Permissions.Extensions[teleport.CertExtensionTeleportRouteToCluster] = c.RouteToCluster + } } if err := cert.SignCert(rand.Reader, signer); err != nil { return nil, err diff --git a/lib/client/api.go b/lib/client/api.go index 719f8d93ceb20..26edefc19352f 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -145,6 +145,10 @@ type Config struct { // port setting via -p flag, otherwise '0' is passed which means "use server default" HostPort int + // JumpHosts if specified are interpreted in a similar way + // as -J flag in ssh - used to dial through + JumpHosts []utils.JumpHost + // WebProxyAddr is the host:port the web proxy can be accessed at. WebProxyAddr string @@ -707,6 +711,9 @@ type ShellCreatedCallback func(s *ssh.Session, c *ssh.Client, terminal io.ReadWr // NewClient creates a TeleportClient object and fully configures it func NewClient(c *Config) (tc *TeleportClient, err error) { + if len(c.JumpHosts) > 1 { + return nil, trace.BadParameter("only one jump host is supported, got %v", len(c.JumpHosts)) + } // validate configuration if c.Username == "" { c.Username, err = Username() @@ -1387,6 +1394,10 @@ func (tc *TeleportClient) getProxySSHPrincipal() string { if tc.DefaultPrincipal != "" { proxyPrincipal = tc.DefaultPrincipal } + if len(tc.JumpHosts) > 1 && tc.JumpHosts[0].Username != "" { + log.Debugf("Setting proxy login to jump host's parameter user %q", tc.JumpHosts[0].Username) + proxyPrincipal = tc.JumpHosts[0].Username + } // see if we already have a signed key in the cache, we'll use that instead if !tc.Config.SkipLocalAuth && tc.LocalAgent() != nil { signers, err := tc.LocalAgent().Signers() @@ -1448,12 +1459,18 @@ func (tc *TeleportClient) connectToProxy(ctx context.Context) (*ProxyClient, err HostKeyCallback: tc.HostKeyCallback, } + sshProxyAddr := tc.Config.SSHProxyAddr + if len(tc.JumpHosts) > 0 { + log.Debugf("Overriding SSH proxy to JumpHosts's address %q", tc.JumpHosts[0].Addr.String()) + sshProxyAddr = tc.JumpHosts[0].Addr.Addr + } + // helper to create a ProxyClient struct makeProxyClient := func(sshClient *ssh.Client, m ssh.AuthMethod) *ProxyClient { return &ProxyClient{ teleportClient: tc, Client: sshClient, - proxyAddress: tc.Config.SSHProxyAddr, + proxyAddress: sshProxyAddr, proxyPrincipal: proxyPrincipal, hostKeyCallback: sshConfig.HostKeyCallback, authMethod: m, @@ -1462,14 +1479,14 @@ func (tc *TeleportClient) connectToProxy(ctx context.Context) (*ProxyClient, err clientAddr: tc.ClientAddr, } } - successMsg := fmt.Sprintf("Successful auth with proxy %v", tc.Config.SSHProxyAddr) + successMsg := fmt.Sprintf("Successful auth with proxy %v", sshProxyAddr) // try to authenticate using every non interactive auth method we have: for i, m := range tc.authMethods() { - log.Infof("Connecting proxy=%v login='%v' method=%d", tc.Config.SSHProxyAddr, sshConfig.User, i) + log.Infof("Connecting proxy=%v login='%v' method=%d", sshProxyAddr, sshConfig.User, i) var sshClient *ssh.Client sshConfig.Auth = []ssh.AuthMethod{m} - sshClient, err = ssh.Dial("tcp", tc.Config.SSHProxyAddr, sshConfig) + sshClient, err = ssh.Dial("tcp", sshProxyAddr, sshConfig) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/client.go b/lib/client/client.go index 65804e254a844..6a76475e6273d 100644 --- a/lib/client/client.go +++ b/lib/client/client.go @@ -438,6 +438,9 @@ func (n *NodeAddr) ProxyFormat() string { // It returns connected and authenticated NodeClient func (proxy *ProxyClient) ConnectToNode(ctx context.Context, nodeAddress NodeAddr, user string, quiet bool) (*NodeClient, error) { log.Infof("Client=%v connecting to node=%v", proxy.clientAddr, nodeAddress) + if len(proxy.teleportClient.JumpHosts) > 0 { + return proxy.PortForwardToNode(ctx, nodeAddress, user, quiet) + } // parse destination first: localAddr, err := utils.ParseAddr("tcp://" + proxy.proxyAddress) @@ -548,6 +551,70 @@ func (proxy *ProxyClient) ConnectToNode(ctx context.Context, nodeAddress NodeAdd return nc, nil } +// PortForwardToNode connects to the ssh server via Proxy +// It returns connected and authenticated NodeClient +func (proxy *ProxyClient) PortForwardToNode(ctx context.Context, nodeAddress NodeAddr, user string, quiet bool) (*NodeClient, error) { + log.Infof("Client=%v jumping to node=%s", proxy.clientAddr, nodeAddress) + + // after auth but before we create the first session, find out if the proxy + // is in recording mode or not + recordingProxy, err := proxy.isRecordingProxy() + if err != nil { + return nil, trace.Wrap(err) + } + + // the client only tries to forward an agent when the proxy is in recording + // mode. we always try and forward an agent here because each new session + // creates a new context which holds the agent. if ForwardToAgent returns an error + // "already have handler for" we ignore it. + if recordingProxy { + err = agent.ForwardToAgent(proxy.Client, proxy.teleportClient.localAgent.Agent) + if err != nil && !strings.Contains(err.Error(), "agent: already have handler for") { + return nil, trace.Wrap(err) + } + } + + proxyConn, err := proxy.Client.Dial("tcp", nodeAddress.Addr) + if err != nil { + return nil, trace.ConnectionProblem(err, "failed connecting to node %v. %s", nodeAddress, err) + } + + sshConfig := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{proxy.authMethod}, + HostKeyCallback: proxy.hostKeyCallback, + } + conn, chans, reqs, err := newClientConn(ctx, proxyConn, nodeAddress.Addr, sshConfig) + if err != nil { + if utils.IsHandshakeFailedError(err) { + proxyConn.Close() + return nil, trace.AccessDenied(`access denied to %v connecting to %v`, user, nodeAddress) + } + return nil, trace.Wrap(err) + } + + // We pass an empty channel which we close right away to ssh.NewClient + // because the client need to handle requests itself. + emptyCh := make(chan *ssh.Request) + close(emptyCh) + + client := ssh.NewClient(conn, chans, emptyCh) + + nc := &NodeClient{ + Client: client, + Proxy: proxy, + Namespace: defaults.Namespace, + TC: proxy.teleportClient, + } + + // Start a goroutine that will run for the duration of the client to process + // global requests from the client. Teleport clients will use this to update + // terminal sizes when the remote PTY size has changed. + go nc.handleGlobalRequests(ctx, reqs) + + return nc, nil +} + func (c *NodeClient) handleGlobalRequests(ctx context.Context, requestCh <-chan *ssh.Request) { for { select { diff --git a/lib/services/authority.go b/lib/services/authority.go index 4955da7b88f02..dc9c585c7835a 100644 --- a/lib/services/authority.go +++ b/lib/services/authority.go @@ -104,6 +104,10 @@ type UserCertParams struct { Roles []string // CertificateFormat is the format of the SSH certificate. CertificateFormat string + // RouteToCluster specifies the target cluster + // if present in the certificate, will be used + // to route the requests to + RouteToCluster string } // CertRoles defines certificate roles diff --git a/lib/srv/authhandlers.go b/lib/srv/authhandlers.go index 53f598e0ae079..a0e838655d589 100644 --- a/lib/srv/authhandlers.go +++ b/lib/srv/authhandlers.go @@ -54,7 +54,7 @@ type AuthHandlers struct { AccessPoint auth.AccessPoint } -// BuildIdentityContext returns an IdentityContext populated with information +// CreateIdentityContext returns an IdentityContext populated with information // about the logged in user on the connection. func (h *AuthHandlers) CreateIdentityContext(sconn *ssh.ServerConn) (IdentityContext, error) { identity := IdentityContext{ @@ -72,10 +72,10 @@ func (h *AuthHandlers) CreateIdentityContext(sconn *ssh.ServerConn) (IdentityCon if err != nil { return IdentityContext{}, trace.Wrap(err) } + identity.RouteToCluster = certificate.Extensions[teleport.CertExtensionTeleportRouteToCluster] if certificate.ValidBefore != 0 { identity.CertValidBefore = time.Unix(int64(certificate.ValidBefore), 0) } - certAuthority, err := h.authorityForCert(services.UserCA, certificate.SignatureKey) if err != nil { return IdentityContext{}, trace.Wrap(err) diff --git a/lib/srv/ctx.go b/lib/srv/ctx.go index 6164245d200d0..95887c4315bbd 100644 --- a/lib/srv/ctx.go +++ b/lib/srv/ctx.go @@ -136,6 +136,9 @@ type IdentityContext struct { // CertValidBefore is set to the expiry time of a certificate, or // empty, if cert does not expire CertValidBefore time.Time + + // RouteToCluster is derived from the certificate + RouteToCluster string } // GetCertificate parses the SSH certificate bytes and returns a *ssh.Certificate. @@ -522,6 +525,17 @@ func (c *ServerContext) Close() error { return nil } +// CancelContext is a context associated with server context, +// closed whenever this server context is closed +func (c *ServerContext) CancelContext() context.Context { + return c.cancelContext +} + +// Cancel is a function that triggers closure +func (c *ServerContext) Cancel() context.CancelFunc { + return c.cancel +} + // SendExecResult sends the result of execution of the "exec" command over the // ExecResultCh. func (c *ServerContext) SendExecResult(r ExecResult) { diff --git a/lib/srv/keepalive.go b/lib/srv/keepalive.go index 3e9edaca046c5..555a050b4232c 100644 --- a/lib/srv/keepalive.go +++ b/lib/srv/keepalive.go @@ -77,7 +77,7 @@ func StartKeepAliveLoop(p KeepAliveParams) { for _, conn := range p.Conns { ok := sendKeepAliveWithTimeout(conn, defaults.ReadHeadersTimeout, p.CloseContext) if ok { - sentCount += 1 + sentCount++ } } if sentCount == len(p.Conns) { diff --git a/lib/srv/regular/proxy.go b/lib/srv/regular/proxy.go index 78eb2867fafd7..d1babfb18ad7d 100644 --- a/lib/srv/regular/proxy.go +++ b/lib/srv/regular/proxy.go @@ -44,12 +44,8 @@ import ( // proxySubsys implements an SSH subsystem for proxying listening sockets from // remote hosts to a proxy client (AKA port mapping) type proxySubsys struct { + proxySubsysConfig log *logrus.Entry - srv *Server - host string - port string - namespace string - clusterName string closeC chan struct{} error error closeOnce sync.Once @@ -109,26 +105,75 @@ func parseProxySubsys(request string, srv *Server, ctx *srv.ServerContext) (*pro return nil, trace.BadParameter(paramMessage) } } - if clusterName != "" && srv.proxyTun != nil { - _, err := srv.proxyTun.GetSite(clusterName) + + return newProxySubsys(proxySubsysConfig{ + namespace: namespace, + srv: srv, + ctx: ctx, + host: targetHost, + port: targetPort, + clusterName: clusterName, + }) +} + +// proxySubsysConfig is a proxy subsystem configuration +type proxySubsysConfig struct { + namespace string + host string + port string + clusterName string + srv *Server + ctx *srv.ServerContext +} + +func (p *proxySubsysConfig) String() string { + return fmt.Sprintf("host=%v, port=%v, cluster=%v", p.host, p.port, p.clusterName) +} + +// CheckAndSetDefaults checks and sets defaults +func (p *proxySubsysConfig) CheckAndSetDefaults() error { + if p.namespace == "" { + p.namespace = defaults.Namespace + } + if p.srv == nil { + return trace.BadParameter("missing parameter server") + } + if p.ctx == nil { + return trace.BadParameter("missing parameter context") + } + if p.clusterName == "" && p.ctx.Identity.RouteToCluster != "" { + log.Debugf("Proxy subsystem: routing user %q to cluster %q based on the route to cluster extension.", + p.ctx.Identity.TeleportUser, p.ctx.Identity.RouteToCluster, + ) + p.clusterName = p.ctx.Identity.RouteToCluster + } + if p.clusterName != "" && p.srv.proxyTun != nil { + _, err := p.srv.proxyTun.GetSite(p.clusterName) if err != nil { - return nil, trace.BadParameter("invalid format for proxy request: unknown cluster %q in %q", clusterName, request) + return trace.BadParameter("invalid format for proxy request: unknown cluster %q", p.clusterName) } } + return nil +} + +// newProxySubsys is a helper that creates a proxy subsystem from +// a port forwarding request, used to implement ProxyJump feature in proxy +// and reuse the code +func newProxySubsys(cfg proxySubsysConfig) (*proxySubsys, error) { + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + log.Debugf("newProxySubsys(%v).", cfg) return &proxySubsys{ + proxySubsysConfig: cfg, log: logrus.WithFields(logrus.Fields{ trace.Component: teleport.ComponentSubsystemProxy, trace.ComponentFields: map[string]string{}, }), - namespace: namespace, - srv: srv, - host: targetHost, - port: targetPort, - clusterName: clusterName, closeC: make(chan struct{}), - agent: ctx.GetAgent(), - agentChannel: ctx.GetAgentChannel(), + agent: cfg.ctx.GetAgent(), + agentChannel: cfg.ctx.GetAgentChannel(), }, nil } @@ -181,8 +226,8 @@ func (t *proxySubsys) Start(sconn *ssh.ServerConn, ch ssh.Channel, req *ssh.Requ if site == nil { sites := tunnel.GetSites() if len(sites) == 0 { - t.log.Errorf("Not connected to any remote clusters") - return trace.Errorf("no connected sites") + t.log.Error("Not connected to any remote clusters") + return trace.NotFound("no connected sites") } site = sites[0] t.clusterName = site.GetName() diff --git a/lib/srv/regular/sshserver.go b/lib/srv/regular/sshserver.go index d51daae564a7c..ee08e073c2975 100644 --- a/lib/srv/regular/sshserver.go +++ b/lib/srv/regular/sshserver.go @@ -777,6 +777,13 @@ func (s *Server) HandleRequest(r *ssh.Request) { } } +const ( + // ChanDirectTCPIP is a direct tcp ip channel + ChanDirectTCPIP = "direct-tcpip" + // ChanSession is a SSH session channel + ChanSession = "session" +) + // HandleNewChan is called when new channel is opened func (s *Server) HandleNewChan(wconn net.Conn, sconn *ssh.ServerConn, nch ssh.NewChannel) { identityContext, err := s.authHandlers.CreateIdentityContext(sconn) @@ -787,10 +794,28 @@ func (s *Server) HandleNewChan(wconn net.Conn, sconn *ssh.ServerConn, nch ssh.Ne channelType := nch.ChannelType() if s.proxyMode { + switch channelType { + // Channels of type "direct-tcpip", for proxies, it's equivalent + // of teleport proxy: subsystem + case ChanDirectTCPIP: + req, err := sshutils.ParseDirectTCPIPReq(nch.ExtraData()) + if err != nil { + log.Errorf("Failed to parse request data: %v, err: %v.", string(nch.ExtraData()), err) + nch.Reject(ssh.UnknownChannelType, "failed to parse direct-tcpip request") + return + } + ch, _, err := nch.Accept() + if err != nil { + log.Warnf("Unable to accept channel: %v.", err) + nch.Reject(ssh.ConnectionFailed, fmt.Sprintf("unable to accept channel: %v", err)) + return + } + go s.handleProxyJump(wconn, sconn, identityContext, ch, *req) + return // Channels of type "session" handle requests that are involved in running // commands on a server. In the case of proxy mode subsystem and agent // forwarding requests occur over the "session" channel. - if channelType == "session" { + case ChanSession: ch, requests, err := nch.Accept() if err != nil { log.Warnf("Unable to accept channel: %v.", err) @@ -798,16 +823,17 @@ func (s *Server) HandleNewChan(wconn net.Conn, sconn *ssh.ServerConn, nch ssh.Ne return } go s.handleSessionRequests(wconn, sconn, identityContext, ch, requests) - } else { + return + default: nch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %v", channelType)) + return } - return } switch channelType { // Channels of type "session" handle requests that are involved in running // commands on a server, subsystem requests, and agent forwarding. - case "session": + case ChanSession: ch, requests, err := nch.Accept() if err != nil { log.Warnf("Unable to accept channel: %v.", err) @@ -816,7 +842,7 @@ func (s *Server) HandleNewChan(wconn net.Conn, sconn *ssh.ServerConn, nch ssh.Ne } go s.handleSessionRequests(wconn, sconn, identityContext, ch, requests) // Channels of type "direct-tcpip" handles request for port forwarding. - case "direct-tcpip": + case ChanDirectTCPIP: req, err := sshutils.ParseDirectTCPIPReq(nch.ExtraData()) if err != nil { log.Errorf("Failed to parse request data: %v, err: %v.", string(nch.ExtraData()), err) @@ -865,7 +891,7 @@ func (s *Server) handleDirectTCPIPRequest(wconn net.Conn, sconn *ssh.ServerConn, // If PAM is enabled check the account and open a session. var pamContext *pam.PAM - if s.pamConfig.Enabled { + if s.pamConfig != nil && s.pamConfig.Enabled { // Note, stdout/stderr is discarded here, otherwise MOTD would be printed to // the users screen during port forwarding. pamContext, err = pam.Open(&pam.Config{ @@ -916,7 +942,7 @@ func (s *Server) handleDirectTCPIPRequest(wconn net.Conn, sconn *ssh.ServerConn, wg.Wait() // If PAM is enabled, close the PAM context after port forwarding is complete. - if s.pamConfig.Enabled { + if s.pamConfig != nil && s.pamConfig.Enabled { err = pamContext.Close() if err != nil { ctx.Errorf("Unable to close PAM context for direct-tcpip request: %v.", err) @@ -1227,6 +1253,107 @@ func (s *Server) handleRecordingProxy(req *ssh.Request) { log.Debugf("Replied to global request (%v, %v): %v", req.Type, req.WantReply, recordingProxy) } +// handleProxyJump handles ProxyJump request that is executed via direct tcp-ip dial on the proxy +func (s *Server) handleProxyJump(conn net.Conn, sconn *ssh.ServerConn, identityContext srv.IdentityContext, ch ssh.Channel, req sshutils.DirectTCPIPReq) { + // Create context for this channel. This context will be closed when the + // session request is complete. + ctx, err := srv.NewServerContext(s, sconn, identityContext) + if err != nil { + log.Errorf("Unable to create connection context: %v.", err) + ch.Stderr().Write([]byte("Unable to create connection context.")) + return + } + ctx.Connection = conn + ctx.IsTestStub = s.isTestStub + ctx.AddCloser(ch) + defer ctx.Close() + + clusterConfig, err := s.GetAccessPoint().GetClusterConfig() + if err != nil { + log.Errorf("Unable to fetch cluster config: %v.", err) + ch.Stderr().Write([]byte("Unable to fetch cluster configuration.")) + return + } + + // force agent forward, because in recording mode proxy needs + // client's agent to authenticate to the target server + // + // When proxy is in "Recording mode" the following will happen with SSH: + // + // $ ssh -J user@teleport.proxy:3023 -p 3022 user@target -F ./forward.config + // + // Where forward.config enables agent forwarding: + // + // Host teleport.proxy + // ForwardAgent yes + // + // This will translate to ProxyCommand: + // + // exec ssh -l user -p 3023 -F ./forward.config -vvv -W 'target:3022' teleport.proxy + // + // -W means establish direct tcp-ip, and in SSH 2.0 session implementation, + // this gets called before agent forwarding is requested: + // + // https://github.com/openssh/openssh-portable/blob/master/ssh.c#L1884 + // + // so in recording mode, proxy is forced to request agent forwarding + // "out of band", before SSH client actually asks for it + // which is a hack, but the only way we can think of making it work, + // ideas are appreciated. + if clusterConfig.GetSessionRecording() == services.RecordAtProxy { + err = s.handleAgentForwardProxy(&ssh.Request{}, ctx) + if err != nil { + log.Warningf("Failed to request agent in recording mode: %v", err) + ch.Stderr().Write([]byte("Failed to request agent")) + return + } + } + + // The keep-alive loop will keep pinging the remote server and after it has + // missed a certain number of keep-alive requests it will cancel the + // closeContext which signals the server to shutdown. + go srv.StartKeepAliveLoop(srv.KeepAliveParams{ + Conns: []srv.RequestSender{ + sconn, + }, + Interval: clusterConfig.GetKeepAliveInterval(), + MaxCount: clusterConfig.GetKeepAliveCountMax(), + CloseContext: ctx.CancelContext(), + // Looks liks this is this the best way to signal + // close to the proxy subsystem, as it will close + // the channel that proxy subsystem is blocked on. + CloseCancel: func() { + if err := ctx.Close(); err != nil { + log.Warningf("Failed to close: %v.", err) + } + }, + }) + + subsys, err := newProxySubsys(proxySubsysConfig{ + host: req.Host, + port: fmt.Sprintf("%v", req.Port), + srv: s, + ctx: ctx, + }) + if err != nil { + log.Errorf("Unable instantiate proxy subsystem: %v.", err) + ch.Stderr().Write([]byte("Unable to instantiate proxy subsystem.")) + return + } + + if err := subsys.Start(sconn, ch, &ssh.Request{}, ctx); err != nil { + log.Errorf("Unable to start proxy subsystem: %v.", err) + ch.Stderr().Write([]byte("Unable to start proxy subsystem.")) + return + } + + if err := subsys.Wait(); err != nil { + log.Errorf("Proxy subsystem failed: %v.", err) + ch.Stderr().Write([]byte("Proxy subsystem failed.")) + return + } +} + func (s *Server) replyError(ch ssh.Channel, req *ssh.Request, err error) { log.Error(err) message := []byte(utils.UserMessageFromError(err)) @@ -1239,7 +1366,7 @@ func (s *Server) replyError(ch ssh.Channel, req *ssh.Request, err error) { func (s *Server) parseSubsystemRequest(req *ssh.Request, ctx *srv.ServerContext) (srv.Subsystem, error) { var r sshutils.SubsystemReq if err := ssh.Unmarshal(req.Payload, &r); err != nil { - return nil, fmt.Errorf("failed to parse subsystem request, error: %v", err) + return nil, trace.BadParameter("failed to parse subsystem request: %v", err) } if s.proxyMode && strings.HasPrefix(r.Name, "proxy:") { return parseProxySubsys(r.Name, s, ctx) diff --git a/lib/utils/proxyjump.go b/lib/utils/proxyjump.go new file mode 100644 index 0000000000000..2aa350894befc --- /dev/null +++ b/lib/utils/proxyjump.go @@ -0,0 +1,58 @@ +/* +Copyright 2019 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 utils + +import ( + "regexp" + "strings" + + "github.com/gravitational/trace" +) + +var reProxyJump = regexp.MustCompile( + // optional username, note that outside group + `(?:(?P[^@\:]+)@)?(?P[^\@]+)`, +) + +// JumpHost is a target jump host +type JumpHost struct { + // Username to login as + Username string + // Addr is a target addr + Addr NetAddr +} + +// ParseProxyJump parses strings like user@host:port,bob@host:port +func ParseProxyJump(in string) ([]JumpHost, error) { + if in == "" { + return nil, trace.BadParameter("missing proxyjump") + } + parts := strings.Split(in, ",") + out := make([]JumpHost, 0, len(parts)) + for _, part := range parts { + match := reProxyJump.FindStringSubmatch(strings.TrimSpace(part)) + if len(match) == 0 { + return nil, trace.BadParameter("could not parse %q, expected format user@host:port,user@host:port", in) + } + addr, err := ParseAddr(match[2]) + if err != nil { + return nil, trace.Wrap(err) + } + out = append(out, JumpHost{Username: match[1], Addr: *addr}) + } + return out, nil +} diff --git a/lib/utils/proxyjump_test.go b/lib/utils/proxyjump_test.go new file mode 100644 index 0000000000000..23e5b61a8e05a --- /dev/null +++ b/lib/utils/proxyjump_test.go @@ -0,0 +1,65 @@ +/* +Copyright 2019 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 utils + +import ( + "gopkg.in/check.v1" +) + +func (s *UtilsSuite) TestProxyJumpParsing(c *check.C) { + type tc struct { + in string + out []JumpHost + err error + } + testCases := []tc{ + { + in: "host:port", + out: []JumpHost{{Addr: NetAddr{Addr: "host:port", AddrNetwork: "tcp"}}}, + }, + { + in: "host", + out: []JumpHost{{Addr: NetAddr{Addr: "host", AddrNetwork: "tcp"}}}, + }, + { + in: "bob@host", + out: []JumpHost{{Username: "bob", Addr: NetAddr{Addr: "host", AddrNetwork: "tcp"}}}, + }, + { + in: "alice@127.0.0.1:7777", + out: []JumpHost{{Username: "alice", Addr: NetAddr{Addr: "127.0.0.1:7777", AddrNetwork: "tcp"}}}, + }, + { + in: "alice@127.0.0.1:7777, bob@localhost", + out: []JumpHost{{Username: "alice", Addr: NetAddr{Addr: "127.0.0.1:7777", AddrNetwork: "tcp"}}, {Username: "bob", Addr: NetAddr{Addr: "localhost", AddrNetwork: "tcp"}}}, + }, + { + in: "alice@[::1]:7777, bob@localhost", + out: []JumpHost{{Username: "alice", Addr: NetAddr{Addr: "[::1]:7777", AddrNetwork: "tcp"}}, {Username: "bob", Addr: NetAddr{Addr: "localhost", AddrNetwork: "tcp"}}}, + }, + } + for i, tc := range testCases { + comment := check.Commentf("Test case %v: %q", i, tc.in) + re, err := ParseProxyJump(tc.in) + if tc.err == nil { + c.Assert(err, check.IsNil, comment) + c.Assert(re, check.DeepEquals, tc.out) + } else { + c.Assert(err, check.FitsTypeOf, tc.err) + } + } +} diff --git a/tool/tsh/tsh.go b/tool/tsh/tsh.go index b793849b541c9..1f51fb73e9202 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -95,6 +95,9 @@ type CLIConf struct { DynamicForwardedPorts []string // ForwardAgent agent to target node. Equivalent of -A for OpenSSH. ForwardAgent bool + // ProxyJump is an optional -J flag pointing to the list of jumphosts, + // it is an equivalent of --proxy flag in tsh interpretation + ProxyJump string // --local flag for ssh LocalExec bool // SiteName specifies remote site go login to @@ -213,6 +216,7 @@ func Run(args []string, underTest bool) { ssh := app.Command("ssh", "Run shell or execute a command on a remote SSH node") ssh.Arg("[user@]host", "Remote hostname and the login to use").Required().StringVar(&cf.UserHost) ssh.Arg("command", "Command to execute on a remote host").StringsVar(&cf.RemoteCommand) + app.Flag("jumphost", "SSH jumphost").Short('J').StringVar(&cf.ProxyJump) ssh.Flag("port", "SSH port on a remote host").Short('p').Int32Var(&cf.NodePort) ssh.Flag("forward-agent", "Forward agent to target node").Short('A').BoolVar(&cf.ForwardAgent) ssh.Flag("forward", "Forward localhost connections to remote server").Short('L').StringsVar(&cf.LocalForwardPorts) @@ -842,6 +846,15 @@ func makeClient(cf *CLIConf, useProfileLogin bool) (tc *client.TeleportClient, e // 1: start with the defaults c := client.MakeDefaultConfig() + // ProxyJump is an alias of Proxy flag + if cf.ProxyJump != "" { + hosts, err := utils.ParseProxyJump(cf.ProxyJump) + if err != nil { + return nil, trace.Wrap(err) + } + c.JumpHosts = hosts + } + // Look if a user identity was given via -i flag if cf.IdentityFileIn != "" { // Ignore local authentication methods when identity file is provided