From a8b31b8d134ff79b5f7dcabd03fb2cdb318efc93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Smoli=C5=84ski?= Date: Mon, 28 Mar 2022 12:31:05 +0200 Subject: [PATCH] Fix TLS Routing jumphost flow (#11282) --- lib/client/api.go | 60 +++++++++--- tool/tsh/proxy_test.go | 180 ++++++++++++++++++++++++++++++++++++ tool/tsh/tsh_helper_test.go | 92 ++++++++++++++++++ 3 files changed, 321 insertions(+), 11 deletions(-) diff --git a/lib/client/api.go b/lib/client/api.go index fcf3ee2e6792b..e50d7dbeba29d 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -2167,14 +2167,12 @@ func (tc *TeleportClient) connectToProxy(ctx context.Context) (*ProxyClient, err HostKeyCallback: hostKeyCallback, Auth: authMethods, } - log.Infof("Connecting proxy=%v login=%q", sshProxyAddr, sshConfig.User) - sshClient, err := makeProxySSHClient(tc, sshConfig) + sshClient, err := makeProxySSHClient(ctx, tc, sshConfig) if err != nil { return nil, trace.Wrap(err) } - log.Infof("Successful auth with proxy %v.", sshProxyAddr) return &ProxyClient{ teleportClient: tc, Client: sshClient, @@ -2188,26 +2186,66 @@ func (tc *TeleportClient) connectToProxy(ctx context.Context) (*ProxyClient, err }, nil } -func makeProxySSHClient(tc *TeleportClient, sshConfig *ssh.ClientConfig) (*ssh.Client, error) { - if tc.Config.TLSRoutingEnabled { - return makeProxySSHClientWithTLSWrapper(tc, sshConfig) +// makeProxySSHClient creates an SSH client by following steps: +// 1) If the current proxy supports TLS Routing and JumpHost address was not provided use TLSWrapper. +// 2) Check JumpHost raw SSH port or Teleport proxy address. +// In case of proxy web address check if the proxy supports TLS Routing and connect to the proxy with TLSWrapper +// 3) Dial sshProxyAddr with raw SSH Dialer where sshProxyAddress is proxy ssh address or JumpHost address if +// JumpHost address was provided. +func makeProxySSHClient(ctx context.Context, tc *TeleportClient, sshConfig *ssh.ClientConfig) (*ssh.Client, error) { + // Use TLS Routing dialer only if proxy support TLS Routing and JumpHost was not set. + if tc.Config.TLSRoutingEnabled && len(tc.JumpHosts) == 0 { + log.Infof("Connecting to proxy=%v login=%q using TLS Routing", tc.Config.WebProxyAddr, sshConfig.User) + c, err := makeProxySSHClientWithTLSWrapper(ctx, tc, sshConfig, tc.Config.WebProxyAddr) + if err != nil { + return nil, trace.Wrap(err) + } + log.Infof("Successful auth with proxy %v.", tc.Config.WebProxyAddr) + return c, nil } - return makeProxySSHClientDirect(tc, sshConfig) + + sshProxyAddr := tc.Config.SSHProxyAddr + + // Handle situation where a Jump Host was set to proxy web address and Teleport supports TLS Routing. + if len(tc.JumpHosts) > 0 { + sshProxyAddr = tc.JumpHosts[0].Addr.Addr + // Check if JumpHost address is a proxy web address. + resp, err := webclient.Find(&webclient.Config{Context: ctx, ProxyAddr: sshProxyAddr, Insecure: tc.InsecureSkipVerify}) + // If JumpHost address is a proxy web port and proxy supports TLSRouting dial proxy with TLSWrapper. + if err == nil && resp.Proxy.TLSRoutingEnabled { + log.Infof("Connecting to proxy=%v login=%q using TLS Routing JumpHost", sshProxyAddr, sshConfig.User) + c, err := makeProxySSHClientWithTLSWrapper(ctx, tc, sshConfig, sshProxyAddr) + if err != nil { + return nil, trace.Wrap(err) + } + log.Infof("Successful auth with proxy %v.", sshProxyAddr) + return c, nil + } + } + + log.Infof("Connecting to proxy=%v login=%q", sshProxyAddr, sshConfig.User) + client, err := makeProxySSHClientDirect(tc, sshConfig, sshProxyAddr) + if err != nil { + return nil, trace.Wrap(err, "failed to authenticate with proxy %v", sshProxyAddr) + } + log.Infof("Successful auth with proxy %v.", sshProxyAddr) + return client, nil } -func makeProxySSHClientDirect(tc *TeleportClient, sshConfig *ssh.ClientConfig) (*ssh.Client, error) { +func makeProxySSHClientDirect(tc *TeleportClient, sshConfig *ssh.ClientConfig, proxyAddr string) (*ssh.Client, error) { dialer := proxy.DialerFromEnvironment(tc.Config.SSHProxyAddr) - return dialer.Dial("tcp", tc.Config.SSHProxyAddr, sshConfig) + return dialer.Dial("tcp", proxyAddr, sshConfig) } -func makeProxySSHClientWithTLSWrapper(tc *TeleportClient, sshConfig *ssh.ClientConfig) (*ssh.Client, error) { +func makeProxySSHClientWithTLSWrapper(ctx context.Context, tc *TeleportClient, sshConfig *ssh.ClientConfig, proxyAddr string) (*ssh.Client, error) { tlsConfig, err := tc.loadTLSConfig() if err != nil { return nil, trace.Wrap(err) } + tlsConfig.NextProtos = []string{string(alpncommon.ProtocolProxySSH)} dialer := proxy.DialerFromEnvironment(tc.Config.WebProxyAddr, proxy.WithALPNDialer(tlsConfig)) - return dialer.Dial("tcp", tc.Config.WebProxyAddr, sshConfig) + return dialer.Dial("tcp", proxyAddr, sshConfig) } func (tc *TeleportClient) rootClusterName() (string, error) { diff --git a/tool/tsh/proxy_test.go b/tool/tsh/proxy_test.go index ab8af3bee64e7..5fbc857cb6298 100644 --- a/tool/tsh/proxy_test.go +++ b/tool/tsh/proxy_test.go @@ -23,6 +23,7 @@ import ( "os" "os/exec" "os/user" + "path" "path/filepath" "strconv" "testing" @@ -45,6 +46,185 @@ import ( "github.com/gravitational/teleport/lib/utils" ) +// TestTSHSSH verifies "tsh proxy ssh" command. +func TestTSHSSH(t *testing.T) { + lib.SetInsecureDevMode(true) + defer lib.SetInsecureDevMode(false) + + os.RemoveAll(profile.FullProfilePath("")) + t.Cleanup(func() { + os.RemoveAll(profile.FullProfilePath("")) + }) + + s := newTestSuite(t, + withRootConfigFunc(func(cfg *service.Config) { + cfg.Version = defaults.TeleportConfigVersionV2 + cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex) + }), + withLeafCluster(), + withLeafConfigFunc(func(cfg *service.Config) { + cfg.Version = defaults.TeleportConfigVersionV2 + cfg.Proxy.SSHAddr.Addr = localListenerAddr() + }), + ) + + tests := []struct { + name string + fn func(t *testing.T, s *suite) + }{ + {"ssh root cluster access", testRootClusterSSHAccess}, + {"ssh leaf cluster access", testLeafClusterSSHAccess}, + {"ssh jump host access", testJumpHostSSHAccess}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.fn(t, s) + }) + } +} + +func testRootClusterSSHAccess(t *testing.T, s *suite) { + err := Run([]string{ + "login", + "--insecure", + "--debug", + "--auth", s.connector.GetName(), + "--proxy", s.root.Config.Proxy.WebAddr.String(), + }, func(cf *CLIConf) error { + cf.mockSSOLogin = mockSSOLogin(t, s.root.GetAuthServer(), s.user) + return nil + }) + require.NoError(t, err) + err = Run([]string{ + "ssh", + s.root.Config.Hostname, + "echo", "hello", + }) + require.NoError(t, err) + + identityFile := path.Join(t.TempDir(), "identity.pem") + err = Run([]string{ + "login", + "--insecure", + "--debug", + "--auth", s.connector.GetName(), + "--proxy", s.root.Config.Proxy.WebAddr.String(), + "--out", identityFile, + }, func(cf *CLIConf) error { + cf.mockSSOLogin = mockSSOLogin(t, s.root.GetAuthServer(), s.user) + return nil + }) + require.NoError(t, err) + + err = Run([]string{ + "--proxy", s.root.Config.Proxy.WebAddr.String(), + "--insecure", + "-i", identityFile, + "ssh", + "localhost", + "echo", "hello", + }) + require.NoError(t, err) +} + +func testLeafClusterSSHAccess(t *testing.T, s *suite) { + err := Run([]string{ + "login", + "--insecure", + "--debug", + "--auth", s.connector.GetName(), + "--proxy", s.root.Config.Proxy.WebAddr.String(), + s.leaf.Config.Auth.ClusterName.GetClusterName(), + }, func(cf *CLIConf) error { + cf.mockSSOLogin = mockSSOLogin(t, s.root.GetAuthServer(), s.user) + return nil + }) + require.NoError(t, err) + + err = Run([]string{ + "ssh", + s.leaf.Config.Hostname, + "echo", "hello", + }) + require.NoError(t, err) + + identityFile := path.Join(t.TempDir(), "identity.pem") + err = Run([]string{ + "login", + "--insecure", + "--debug", + "--auth", s.connector.GetName(), + "--proxy", s.root.Config.Proxy.WebAddr.String(), + "--out", identityFile, + }, func(cf *CLIConf) error { + cf.mockSSOLogin = mockSSOLogin(t, s.root.GetAuthServer(), s.user) + return nil + }) + require.NoError(t, err) + + err = Run([]string{ + "--proxy", s.root.Config.Proxy.WebAddr.String(), + "--insecure", + "-i", identityFile, + "ssh", + "--cluster", s.leaf.Config.Auth.ClusterName.GetClusterName(), + s.leaf.Config.Hostname, + "echo", "hello", + }) + require.NoError(t, err) +} + +func testJumpHostSSHAccess(t *testing.T, s *suite) { + err := Run([]string{ + "login", + "--insecure", + "--auth", s.connector.GetName(), + "--proxy", s.root.Config.Proxy.WebAddr.String(), + s.root.Config.Auth.ClusterName.GetClusterName(), + }, func(cf *CLIConf) error { + cf.mockSSOLogin = mockSSOLogin(t, s.root.GetAuthServer(), s.user) + return nil + }) + require.NoError(t, err) + + err = Run([]string{ + "login", + "--insecure", + s.leaf.Config.Auth.ClusterName.GetClusterName(), + }, func(cf *CLIConf) error { + cf.mockSSOLogin = mockSSOLogin(t, s.root.GetAuthServer(), s.user) + return nil + }) + require.NoError(t, err) + + // Connect to leaf node though jump host set to leaf proxy SSH port. + err = Run([]string{ + "ssh", + "--insecure", + "-J", s.leaf.Config.Proxy.SSHAddr.Addr, + s.leaf.Config.Hostname, + "echo", "hello", + }, func(cf *CLIConf) error { + cf.mockSSOLogin = mockSSOLogin(t, s.root.GetAuthServer(), s.user) + return nil + }) + require.NoError(t, err) + + // Connect to leaf node though jump host set to proxy web port where TLS Routing is enabled. + err = Run([]string{ + "ssh", + "--insecure", + "-J", s.leaf.Config.Proxy.WebAddr.Addr, + s.leaf.Config.Hostname, + "echo", "hello", + }, func(cf *CLIConf) error { + cf.mockSSOLogin = mockSSOLogin(t, s.root.GetAuthServer(), s.user) + return nil + }) + require.NoError(t, err) +} + // TestProxySSHDial verifies "tsh proxy ssh" command. func TestProxySSHDial(t *testing.T) { createAgent(t) diff --git a/tool/tsh/tsh_helper_test.go b/tool/tsh/tsh_helper_test.go index 3fc62aa87e720..387d0487523e6 100644 --- a/tool/tsh/tsh_helper_test.go +++ b/tool/tsh/tsh_helper_test.go @@ -31,6 +31,7 @@ import ( type suite struct { root *service.TeleportProcess + leaf *service.TeleportProcess connector types.OIDCConnector user types.User } @@ -105,8 +106,77 @@ func (s *suite) setupRootCluster(t *testing.T, options testSuiteOptions) { t.Cleanup(func() { require.NoError(t, s.root.Close()) }) } +func (s *suite) setupLeafCluster(t *testing.T, options testSuiteOptions) { + fileConfig := &config.FileConfig{ + Version: "v2", + Global: config.Global{ + DataDir: t.TempDir(), + NodeName: "localnode", + }, + SSH: config.SSH{ + Service: config.Service{ + EnabledFlag: "true", + ListenAddress: localListenerAddr(), + }, + }, + Proxy: config.Proxy{ + Service: config.Service{ + EnabledFlag: "true", + }, + WebAddr: localListenerAddr(), + }, + Auth: config.Auth{ + Service: config.Service{ + EnabledFlag: "true", + ListenAddress: localListenerAddr(), + }, + ClusterName: "leaf1", + ProxyListenerMode: types.ProxyListenerMode_Multiplex, + }, + } + + cfg := service.MakeDefaultConfig() + err := config.ApplyFileConfig(fileConfig, cfg) + require.NoError(t, err) + + user, err := user.Current() + require.NoError(t, err) + + cfg.Proxy.DisableWebInterface = true + sshLoginRole, err := types.NewRole("ssh-login", types.RoleSpecV4{ + Allow: types.RoleConditions{ + Logins: []string{user.Username}, + }, + }) + require.NoError(t, err) + + tc, err := types.NewTrustedCluster("root-cluster", types.TrustedClusterSpecV2{ + Enabled: true, + Token: staticToken, + ProxyAddress: s.root.Config.Proxy.WebAddr.String(), + ReverseTunnelAddress: s.root.Config.Proxy.WebAddr.String(), + RoleMap: []types.RoleMapping{ + { + Remote: "access", + Local: []string{"access", "ssh-login"}, + }, + }, + }) + require.NoError(t, err) + cfg.Auth.Resources = []types.Resource{sshLoginRole} + if options.leafConfigFunc != nil { + options.leafConfigFunc(cfg) + } + s.leaf = runTeleport(t, cfg) + + _, err = s.leaf.GetAuthServer().UpsertTrustedCluster(s.leaf.ExitContext(), tc) + require.NoError(t, err) +} + type testSuiteOptions struct { rootConfigFunc func(cfg *service.Config) + leafConfigFunc func(cfg *service.Config) + leafCluster bool } type testSuiteOptionFunc func(o *testSuiteOptions) @@ -117,6 +187,18 @@ func withRootConfigFunc(fn func(cfg *service.Config)) testSuiteOptionFunc { } } +func withLeafConfigFunc(fn func(cfg *service.Config)) testSuiteOptionFunc { + return func(o *testSuiteOptions) { + o.leafConfigFunc = fn + } +} + +func withLeafCluster() testSuiteOptionFunc { + return func(o *testSuiteOptions) { + o.leafCluster = true + } +} + func newTestSuite(t *testing.T, opts ...testSuiteOptionFunc) *suite { var options testSuiteOptions for _, opt := range opts { @@ -125,6 +207,16 @@ func newTestSuite(t *testing.T, opts ...testSuiteOptionFunc) *suite { s := &suite{} s.setupRootCluster(t, options) + + if options.leafCluster || options.leafConfigFunc != nil { + s.setupLeafCluster(t, options) + require.Eventually(t, func() bool { + rt, err := s.root.GetAuthServer().GetTunnelConnections(s.leaf.Config.Auth.ClusterName.GetClusterName()) + require.NoError(t, err) + return len(rt) == 1 + }, time.Second*10, time.Second) + } + return s }