Files
teleport/tool/tsh/common/proxy_test.go
T
rosstimothy 5870768c88 Stop using text/template and html/template to enable DCE (#65401)
All existing template usage was converted to make use of
github.com/DataDog/datadog-agent/pkg/template. The DataDog package
is a fork of the stdlib template packages with method calling
removed so that reflect.MethodByName does not prevent DCE.

There were a handful of method calls in our templates that were trivially
changed to use custom functions defined via FuncMaps or specifying the
calculated value to the template instead of calling a function in the template.

A future change will add linter rules to prevent new text/template or
html/imports from landing. They have not been included here so as
to not to break enterprise code while it is migrated.
2026-04-22 15:54:43 +00:00

1797 lines
53 KiB
Go

/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package common
import (
"bufio"
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"testing"
"time"
template "github.com/DataDog/datadog-agent/pkg/template/text"
"github.com/gravitational/trace"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh/agent"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/api/utils/retryutils"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/auth/mocku2f"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/client/db/dbcmd"
"github.com/gravitational/teleport/lib/client/identityfile"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/service"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/sshagent"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils/testutils"
testserver "github.com/gravitational/teleport/tool/teleport/testenv"
)
// TestSSH verifies "tsh ssh" command.
func TestSSH(t *testing.T) {
if webpkiCACert == nil {
t.Skip("the current platform doesn't support adding CAs to the system pool")
}
d := t.TempDir()
webCertPath := filepath.Join(d, "cert.pem")
webKeyPath := filepath.Join(d, "key.pem")
generateWebPKICert(t, webCertPath, webKeyPath)
s := newTestSuite(t,
withRootConfigFunc(func(cfg *servicecfg.Config) {
cfg.Version = defaults.TeleportConfigVersionV2
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
cfg.Auth.SessionRecordingConfig.SetMode(types.RecordAtNodeSync)
cfg.Proxy.KeyPairs = []servicecfg.KeyPairPath{{
PrivateKey: webKeyPath,
Certificate: webCertPath,
}}
}),
withLeafCluster(),
withLeafConfigFunc(func(cfg *servicecfg.Config) {
cfg.Version = defaults.TeleportConfigVersionV2
cfg.Auth.SessionRecordingConfig.SetMode(types.RecordAtNodeSync)
}),
)
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) {
tshHome, _ := mustLoginLegacy(t, s)
err := Run(context.Background(), []string{
"ssh",
s.root.Config.Hostname,
"echo", "hello",
}, setHomePath(tshHome))
require.NoError(t, err)
identityFile := mustLoginIdentity(t, s)
err = Run(context.Background(), []string{
"--proxy", s.root.Config.Proxy.WebAddr.String(),
"-i", identityFile,
"ssh",
"localhost",
"echo", "hello",
}, setIdentity(identityFile))
require.NoError(t, err)
}
func testLeafClusterSSHAccess(t *testing.T, s *suite) {
tshHome, _ := mustLoginLegacy(t, s, s.leaf.Config.Auth.ClusterName.GetClusterName())
require.Eventually(t, func() bool {
err := Run(context.Background(), []string{
"ssh",
"--proxy", s.root.Config.Proxy.WebAddr.String(),
s.leaf.Config.Hostname,
"echo", "hello",
}, setHomePath(tshHome))
t.Logf("ssh to leaf failed %v", err)
return err == nil
}, 5*time.Second, time.Second)
identityFile := mustLoginIdentity(t, s)
err := Run(context.Background(), []string{
"--proxy", s.root.Config.Proxy.WebAddr.String(),
"-i", identityFile,
"ssh",
"--cluster", s.leaf.Config.Auth.ClusterName.GetClusterName(),
s.leaf.Config.Hostname,
"echo", "hello",
}, setIdentity(identityFile))
require.NoError(t, err)
}
func testJumpHostSSHAccess(t *testing.T, s *suite) {
// login to root
tshHome, _ := mustLoginLegacy(t, s, s.root.Config.Auth.ClusterName.GetClusterName())
// Switch to leaf cluster
err := Run(context.Background(), []string{
"login",
s.leaf.Config.Auth.ClusterName.GetClusterName(),
}, s.setMockSSOLogin(t), setHomePath(tshHome))
require.NoError(t, err)
// Connect to leaf node though jump host set to leaf proxy SSH port.
err = Run(context.Background(), []string{
"ssh",
"-J", s.leaf.Config.Proxy.SSHAddr.Addr,
s.leaf.Config.Hostname,
"echo", "hello",
}, s.setMockSSOLogin(t), setHomePath(tshHome))
require.NoError(t, err)
t.Run("root cluster online", func(t *testing.T) {
// Connect to leaf node though jump host set to proxy web port where TLS Routing is enabled.
err = Run(context.Background(), []string{
"ssh",
"-J", s.leaf.Config.Proxy.WebAddr.Addr,
s.leaf.Config.Hostname,
"echo", "hello",
}, s.setMockSSOLogin(t), setHomePath(tshHome))
require.NoError(t, err)
})
t.Run("root cluster offline", func(t *testing.T) {
// Terminate root cluster.
err = s.root.Close()
require.NoError(t, err)
// Check JumpHost flow when root cluster is offline.
err = Run(context.Background(), []string{
"ssh",
"-J", s.leaf.Config.Proxy.WebAddr.Addr,
s.leaf.Config.Hostname,
"echo", "hello",
}, s.setMockSSOLogin(t), setHomePath(tshHome))
require.NoError(t, err)
})
}
func generateWebPKICert(t *testing.T, certPath, keyPath string) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
require.NotNil(t, keyPEM)
ca := &tlsca.CertAuthority{
Cert: webpkiCACert,
Signer: webpkiCAKey,
}
certPEM, err := ca.GenerateCertificate(tlsca.CertificateRequest{
PublicKey: key.Public(),
Subject: pkix.Name{CommonName: "proxy web addr cert"},
NotAfter: time.Now().Add(12 * time.Hour),
DNSNames: []string{"127.0.0.1"},
KeyUsage: x509.KeyUsageDigitalSignature,
})
require.NoError(t, err)
require.NoError(t, os.WriteFile(certPath, certPEM, 0o644))
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o644))
}
// TestWithRsync tests that Teleport works with rsync.
func TestWithRsync(t *testing.T) {
disableAgent(t)
_, err := exec.LookPath("rsync")
require.NoError(t, err)
accessUser, err := types.NewUser("access")
require.NoError(t, err)
accessUser.SetRoles([]string{"access"})
user, err := user.Current()
require.NoError(t, err)
accessUser.SetLogins([]string{user.Username})
connector := mockConnector(t)
serverOpts := []testserver.TestServerOptFunc{
testserver.WithBootstrap(connector, accessUser),
testserver.WithHostname("node01"),
testserver.WithClusterName("root"),
}
process, err := testserver.NewTeleportProcess(t.TempDir(), serverOpts...)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, process.Close())
require.NoError(t, process.Wait())
})
tshHome, _ := mustLogin(t, process, accessUser, connector.GetName())
testBin, err := os.Executable()
require.NoError(t, err)
host, port, err := net.SplitHostPort(process.Config.SSH.Addr.String())
require.NoError(t, err)
proxyAddr, err := process.ProxyWebAddr()
require.NoError(t, err)
var mockHeadlessAddr string
tests := []struct {
name string
setup func(t *testing.T, dir string)
createCmd func(ctx context.Context, dir, src, dst string) *exec.Cmd
}{
{
name: "with OpenSSH ssh",
setup: func(t *testing.T, dir string) {
// create ssh client config rsync will use
var buf bytes.Buffer
err = Run(context.Background(), []string{"config"}, setHomePath(tshHome), setOverrideStdout(&buf))
require.NoError(t, err)
configPath := filepath.Join(dir, "ssh_config")
err = os.WriteFile(configPath, buf.Bytes(), 0o600)
require.NoError(t, err)
},
createCmd: func(ctx context.Context, dir, src, dst string) *exec.Cmd {
return exec.CommandContext(
ctx,
"rsync",
// ensure ssh will use the client config that was generated
"-e",
fmt.Sprintf("ssh -F %s -p %s", filepath.Join(dir, "ssh_config"), port),
src,
fmt.Sprintf("%s:%s", host, dst),
)
},
},
{
name: "with headless tsh",
setup: func(t *testing.T, dir string) {
// setup webauthn for headless auth
asrv := process.GetAuthServer()
ctx := context.Background()
_, err = asrv.UpsertAuthPreference(ctx, &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "root",
},
},
})
require.NoError(t, err)
require.EventuallyWithT(t, func(t *assert.CollectT) {
pref, err := asrv.GetAuthPreference(ctx)
require.NoError(t, err)
w, err := pref.GetWebauthn()
require.NoError(t, err)
require.NotNil(t, w)
}, 5*time.Second, 100*time.Millisecond)
token, err := asrv.CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{
Name: accessUser.GetName(),
})
require.NoError(t, err)
tokenID := token.GetName()
res, err := asrv.CreateRegisterChallenge(ctx, &proto.CreateRegisterChallengeRequest{
TokenID: tokenID,
DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN,
DeviceUsage: proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS,
})
require.NoError(t, err)
cc := wantypes.CredentialCreationFromProto(res.GetWebauthn())
device, err := mocku2f.Create()
require.NoError(t, err)
device.SetPasswordless()
ccr, err := device.SignCredentialCreation("https://root", cc)
require.NoError(t, err)
_, err = asrv.ChangeUserAuthentication(ctx, &proto.ChangeUserAuthenticationRequest{
TokenID: tokenID,
NewPassword: []byte(mockHeadlessPassword),
NewMFARegisterResponse: &proto.MFARegisterResponse{
Response: &proto.MFARegisterResponse_Webauthn{
Webauthn: wantypes.CredentialCreationResponseToProto(ccr),
},
},
})
require.NoError(t, err)
// start a listener to use as a way to implement mock
// headless auth on the child 'tsh ssh --headless' process
lis, err := net.Listen("tcp", "127.0.0.1:")
require.NoError(t, err)
t.Cleanup(func() {
lis.Close()
})
mockHeadlessAddr = lis.Addr().String()
go func() {
conn, err := lis.Accept()
if !assert.NoError(t, err) {
return
}
defer conn.Close()
// the child will send the SSH public key and then the TLS
// public key, with a 0 byte after key as a delimiter
reader := bufio.NewReader(conn)
sshPubKey, err := reader.ReadBytes(0)
require.NoError(t, err)
tlsPubKey, err := reader.ReadBytes(0)
require.NoError(t, err)
// generate certificates for our user
clusterName, err := asrv.GetClusterName(ctx)
if !assert.NoError(t, err) {
return
}
sshCert, tlsCert, err := asrv.GenerateUserTestCertsWithContext(ctx, auth.GenerateUserTestCertsRequest{
SSHPubKey: sshPubKey,
TLSPubKey: tlsPubKey,
Username: accessUser.GetName(),
TTL: time.Hour,
Compatibility: constants.CertificateFormatStandard,
RouteToCluster: clusterName.GetClusterName(),
MFAVerified: "mfa-verified",
})
if !assert.NoError(t, err) {
return
}
// load CA cert
authority, err := asrv.GetCertAuthority(
ctx, types.CertAuthID{
Type: types.HostCA,
DomainName: clusterName.GetClusterName(),
}, false)
if !assert.NoError(t, err) {
return
}
// send login response to the client
resp := authclient.SSHLoginResponse{
Username: accessUser.GetName(),
Cert: sshCert,
TLSCert: tlsCert,
HostSigners: authclient.AuthoritiesToTrustedCerts([]types.CertAuthority{authority}),
}
encResp, err := json.Marshal(resp)
if !assert.NoError(t, err) {
return
}
_, err = conn.Write(encResp)
assert.NoError(t, err)
assert.NoError(t, conn.Close())
}()
},
createCmd: func(ctx context.Context, dir, src, dst string) *exec.Cmd {
cmd := exec.CommandContext(
ctx,
"rsync",
// ensure headless tsh will be used to authenticate
"-e",
fmt.Sprintf("%s ssh -d --insecure --headless --proxy=%s --user=%s", testBin, proxyAddr, accessUser.GetName()),
src,
fmt.Sprintf("%s:%s", host, dst),
)
// make the re-exec behave as `tsh` instead of test binary.
cmd.Env = []string{
fmt.Sprintf("%s=%s", tshBinMockHeadlessAddrEnv, mockHeadlessAddr),
tshBinMainTestEnv + "=1",
tshBinMainTestOneshotEnv + "=1",
fmt.Sprintf("%s=%s", types.HomeEnvVar, tshHome),
}
return cmd
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testDir := t.TempDir()
tt.setup(t, testDir)
// create a source file with random contents
srcContents := make([]byte, 1024)
_, err = rand.Read(srcContents)
require.NoError(t, err)
srcPath := filepath.Join(testDir, "src")
err = os.WriteFile(srcPath, srcContents, 0o644)
require.NoError(t, err)
dstPath := filepath.Join(testDir, "dst")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
t.Cleanup(cancel)
cmd := tt.createCmd(ctx, testDir, srcPath, dstPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
err = cmd.Run()
var msg string
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
msg += fmt.Sprintf("exit code: %d\n", exitErr.ExitCode())
}
msg += fmt.Sprintf("stderr: %s", stderr.String())
require.NoError(t, err, msg)
// verify that dst exists and that its contents match src
dstContents, err := os.ReadFile(dstPath)
require.NoError(t, err)
require.Equal(t, srcContents, dstContents)
})
}
}
// TestProxySSH verifies "tsh proxy ssh" functionality
func TestProxySSH(t *testing.T) {
// ssh agent can cause race conditions in parallel tests.
disableAgent(t)
ctx := context.Background()
tests := []struct {
name string
opts []testSuiteOptionFunc
}{
{
name: "TLS routing enabled",
opts: []testSuiteOptionFunc{
withRootConfigFunc(func(cfg *servicecfg.Config) {
cfg.Version = defaults.TeleportConfigVersionV2
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
}),
},
},
{
name: "TLS routing disabled",
opts: []testSuiteOptionFunc{
withRootConfigFunc(func(cfg *servicecfg.Config) {
cfg.Version = defaults.TeleportConfigVersionV2
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Separate)
}),
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := newTestSuite(t, tc.opts...)
proxyRequest := fmt.Sprintf("%s.%s:%d",
s.root.Config.Proxy.SSHAddr.Host(),
s.root.Config.Auth.ClusterName.GetClusterName(),
s.root.Config.SSH.Addr.Port(defaults.SSHServerListenPort))
runProxySSH := func(proxyRequest string, opts ...CliOption) error {
var args []string
if testing.Verbose() {
args = append(args, "--debug")
}
return Run(ctx, append(args,
"--insecure",
"--proxy", s.root.Config.Proxy.WebAddr.Addr,
"proxy", "ssh", proxyRequest,
), opts...)
}
// login to Teleport
homePath, kubeConfigPath := mustLoginLegacy(t, s)
require.Eventually(t, func() bool {
rnodes, _ := s.root.GetAuthServer().GetNodes(context.Background(), "default")
return len(rnodes) != 0
}, time.Second*30, time.Millisecond*100)
t.Run("logged in", func(t *testing.T) {
t.Parallel()
err := runProxySSH(proxyRequest, setHomePath(homePath), setKubeConfigPath(kubeConfigPath))
require.NoError(t, err)
})
t.Run("no-relogin", func(t *testing.T) {
t.Parallel()
err := runProxySSH(proxyRequest, setNoRelogin(), setHomePath(t.TempDir()))
require.Error(t, err)
})
t.Run("re-login", func(t *testing.T) {
t.Parallel()
err := runProxySSH(proxyRequest, setHomePath(t.TempDir()), setKubeConfigPath(filepath.Join(t.TempDir(), teleport.KubeConfigFile)), s.setMockSSOLogin(t))
require.NoError(t, err)
})
t.Run("identity file", func(t *testing.T) {
t.Parallel()
err := runProxySSH(proxyRequest, setIdentity(mustLoginIdentity(t, s)))
require.NoError(t, err)
})
t.Run("invalid node login", func(t *testing.T) {
t.Parallel()
// it's legal to specify any username before the request
invalidLoginRequest := fmt.Sprintf("%s@%s", "invalidUser", proxyRequest)
err := runProxySSH(invalidLoginRequest, setHomePath(homePath), setKubeConfigPath(kubeConfigPath), s.setMockSSOLogin(t))
require.NoError(t, err)
})
})
}
}
// TestProxySSHJumpHost verifies "tsh proxy ssh -J" functionality.
func TestProxySSHJumpHost(t *testing.T) {
ctx := context.Background()
createAgent(t)
listenerModes := []types.ProxyListenerMode{
types.ProxyListenerMode_Multiplex,
types.ProxyListenerMode_Separate,
}
for _, rootListenerMode := range listenerModes {
for _, leafListenerMode := range listenerModes {
rootListenerMode := rootListenerMode
leafListenerMode := leafListenerMode
t.Run(fmt.Sprintf("Proxy listener mode %s for root and %s for leaf", rootListenerMode, leafListenerMode), func(t *testing.T) {
t.Parallel()
accessUser, err := types.NewUser("access")
require.NoError(t, err)
accessUser.SetRoles([]string{"access"})
user, err := user.Current()
require.NoError(t, err)
accessUser.SetLogins([]string{user.Name})
connector := mockConnector(t)
rootServerOpts := []testserver.TestServerOptFunc{
testserver.WithBootstrap(connector, accessUser),
testserver.WithHostname("node01"),
testserver.WithClusterName("root"),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.Auth.NetworkingConfig.SetProxyListenerMode(rootListenerMode)
// Load all CAs on login so that leaf CA is trusted by clients.
cfg.Auth.LoadAllCAs = true
cfg.InsecureMode = true
}),
}
rootServer, err := testserver.NewTeleportProcess(t.TempDir(), rootServerOpts...)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, rootServer.Close())
require.NoError(t, rootServer.Wait())
})
leafServerOpts := []testserver.TestServerOptFunc{
testserver.WithBootstrap(accessUser),
testserver.WithHostname("node02"),
testserver.WithClusterName("leaf"),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.Auth.NetworkingConfig.SetProxyListenerMode(leafListenerMode)
cfg.InsecureMode = true
}),
}
leafServer, err := testserver.NewTeleportProcess(t.TempDir(), leafServerOpts...)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, leafServer.Close())
require.NoError(t, leafServer.Wait())
})
SetupTrustedCluster(ctx, t, rootServer, leafServer)
rootProxyAddr, err := rootServer.ProxyWebAddr()
require.NoError(t, err)
leafProxyAddr, err := leafServer.ProxyWebAddr()
require.NoError(t, err)
// login to root
tshHome := t.TempDir()
err = Run(ctx, []string{
"--insecure",
"login",
"--proxy", rootProxyAddr.String(),
}, setHomePath(tshHome), setMockSSOLogin(rootServer.GetAuthServer(), accessUser, connector.GetName()))
require.NoError(t, err)
// Connect through the leaf proxy jumphost.
err = Run(ctx, []string{
"--debug",
"proxy",
"ssh",
"--no-resume",
"-J", leafProxyAddr.String(),
"node02",
}, setHomePath(tshHome))
require.NoError(t, err)
// Terminate root cluster.
err = rootServer.Close()
require.NoError(t, err)
// We should be able to connect without root online.
err = Run(ctx, []string{
"--debug",
"--insecure",
"proxy",
"ssh",
"--no-resume",
"-J", leafProxyAddr.String(),
"node02",
}, setHomePath(tshHome))
require.NoError(t, err)
})
}
}
}
// TestTSHProxyTemplate verifies connecting with OpenSSH client through the
// local proxy started with "tsh proxy ssh -J" using proxy templates.
func TestTSHProxyTemplate(t *testing.T) {
_, err := exec.LookPath("ssh")
if err != nil {
t.Skip("Skipping test, no ssh binary found.")
}
tshPath, err := os.Executable()
require.NoError(t, err)
s := newTestSuite(t)
tshHome, _ := mustLoginSetEnvLegacy(t, s)
// Create proxy template configuration.
tshConfigFile := filepath.Join(tshHome, client.TSHConfigPath)
require.NoError(t, os.MkdirAll(filepath.Dir(tshConfigFile), 0o777))
require.NoError(t, os.WriteFile(tshConfigFile, fmt.Appendf(nil, `
proxy_templates:
- template: '^(\w+)\.(root):(.+)$'
proxy: "%v"
host: "$1:$3"
`, s.root.Config.Proxy.WebAddr.Addr), 0o644))
// Create SSH config.
sshConfigFile := filepath.Join(tshHome, "sshconfig")
err = os.WriteFile(sshConfigFile, fmt.Appendf(nil, `
Host *
HostName %%h
StrictHostKeyChecking no
ProxyCommand %v -d --insecure proxy ssh -J {{proxy}} %%r@%%h:%%p
`, tshPath), 0o644)
require.NoError(t, err)
// Connect to "rootnode" with OpenSSH.
mustRunOpenSSHCommand(t, sshConfigFile, "rootnode.root",
s.root.Config.SSH.Addr.Port(defaults.SSHServerListenPort), "echo", "hello")
}
// TestTSHConfigConnectWithOpenSSHClient tests OpenSSH configuration generated by tsh config command and
// connects to ssh node using native OpenSSH client with different session recording modes and proxy listener modes.
func TestTSHConfigConnectWithOpenSSHClient(t *testing.T) {
// Only run this test if we have access to the external SSH binary.
_, err := exec.LookPath("ssh")
if err != nil {
t.Skip("Skipping no external SSH binary found.")
}
tests := []struct {
name string
opts []testSuiteOptionFunc
}{
{
name: "node recording mode with TLS routing enabled",
opts: []testSuiteOptionFunc{
withRootConfigFunc(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
cfg.Auth.SessionRecordingConfig.SetMode(types.RecordAtNodeSync)
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
}),
},
},
{
name: "proxy recording mode with TLS routing enabled",
opts: []testSuiteOptionFunc{
withRootConfigFunc(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
cfg.Auth.SessionRecordingConfig.SetMode(types.RecordAtProxySync)
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
}),
},
},
{
// Test with the legacy signature algorithm suite which generates
// RSA keys.
name: "proxy recording mode with TLS routing enabled legacy",
opts: []testSuiteOptionFunc{
withRootConfigFunc(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
cfg.Auth.SessionRecordingConfig.SetMode(types.RecordAtProxySync)
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
cfg.Auth.Preference.SetSignatureAlgorithmSuite(types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_LEGACY)
}),
},
},
{
name: "node recording mode with TLS routing disabled",
opts: []testSuiteOptionFunc{
withRootConfigFunc(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
cfg.Auth.SessionRecordingConfig.SetMode(types.RecordAtNodeSync)
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Separate)
}),
},
},
{
name: "proxy recording mode with TLS routing disabled",
opts: []testSuiteOptionFunc{
withRootConfigFunc(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
cfg.Auth.SessionRecordingConfig.SetMode(types.RecordAtProxySync)
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Separate)
}),
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := newTestSuite(t, tc.opts...)
// Login to the Teleport proxy.
mustLoginSetEnvLegacy(t, s)
// Get SSH config file generated by the 'tsh config' command.
sshConfigFile := mustGetOpenSSHConfigFile(t)
sshConn := fmt.Sprintf("%s.%s", s.root.Config.Hostname, s.root.Config.Auth.ClusterName.GetClusterName())
nodePort := s.root.Config.SSH.Addr.Port(defaults.SSHServerListenPort)
bashCmd := []string{"echo", "hello"}
// Run ssh command using the OpenSSH client.
mustRunOpenSSHCommand(t, sshConfigFile, sshConn, nodePort, bashCmd...)
// Try to run ssh command using the OpenSSH client with invalid node login username.
// Command should fail because nodeLogin 'invalidUser' is not in valid principals.
sshConn = fmt.Sprintf("invalidUser@%s", sshConn)
mustFailToRunOpenSSHCommand(t, sshConfigFile, sshConn, nodePort, bashCmd...)
// Check if failed login attempt event has proper nodeLogin.
mustFindFailedNodeLoginAttempt(t, s, "invalidUser")
})
}
}
func TestEnvVarCommand(t *testing.T) {
t.Parallel()
tests := []struct {
inputFormat string
inputKey string
inputValue string
expectOutput string
expectError bool
}{
{
inputFormat: envVarFormatText,
inputKey: "key",
inputValue: "value",
expectOutput: "key=value",
},
{
inputFormat: envVarFormatUnix,
inputKey: "key",
inputValue: "value",
expectOutput: `export key="value"`,
},
{
inputFormat: envVarFormatWindowsCommandPrompt,
inputKey: "key",
inputValue: "value",
expectOutput: "set key=value",
},
{
inputFormat: envVarFormatWindowsPowershell,
inputKey: "key",
inputValue: "value",
expectOutput: `$Env:key="value"`,
},
{
inputFormat: "unknown",
inputKey: "key",
inputValue: "value",
expectError: true,
},
}
for _, test := range tests {
t.Run(test.inputFormat, func(t *testing.T) {
actualOutput, err := envVarCommand(test.inputFormat, test.inputKey, test.inputValue)
if test.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, test.expectOutput, actualOutput)
}
})
}
}
// TestList verifies "tsh ls" functionality
func TestList(t *testing.T) {
s := newTestSuite(t,
withRootConfigFunc(func(cfg *servicecfg.Config) {
cfg.Version = defaults.TeleportConfigVersionV2
cfg.InsecureMode = true
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
}),
withLeafCluster(),
withLeafConfigFunc(func(cfg *servicecfg.Config) {
cfg.Version = defaults.TeleportConfigVersionV2
cfg.InsecureMode = true
}),
)
rootNodeAddress, err := s.root.NodeSSHAddr()
require.NoError(t, err)
leafNodeAddress, err := s.leaf.NodeSSHAddr()
require.NoError(t, err)
testCases := []struct {
description string
command []string
assertion func(t *testing.T, out []byte)
}{
{
description: "List root cluster nodes",
command: []string{"ls", "-f", "json"},
assertion: func(t *testing.T, out []byte) {
var results []types.ServerV2
require.NoError(t, json.Unmarshal(out, &results))
require.Len(t, results, 1)
require.Equal(t, "rootnode", results[0].Spec.Hostname)
require.Equal(t, rootNodeAddress.String(), results[0].Spec.Addr)
},
},
{
description: "List leaf cluster nodes",
command: []string{"ls", "-c", "leaf1", "-f", "json"},
assertion: func(t *testing.T, out []byte) {
var results []types.ServerV2
require.NoError(t, json.Unmarshal(out, &results))
require.Len(t, results, 1)
require.Equal(t, "leafnode", results[0].Spec.Hostname)
require.Equal(t, leafNodeAddress.String(), results[0].Spec.Addr)
},
},
{
description: "List all clusters nodes",
command: []string{"ls", "-R", "-f", "json"},
assertion: func(t *testing.T, out []byte) {
expected := map[string]string{
"root": "rootnode",
"leaf1": "leafnode",
}
type result struct {
Cluster string `json:"cluster"`
Node types.ServerV2 `json:"node"`
}
var results []result
require.NoError(t, json.Unmarshal(out, &results))
require.Len(t, expected, len(results))
for _, res := range results {
node, ok := expected[res.Cluster]
require.True(t, ok, "expected node to be present for cluster %s", res.Cluster)
require.Equal(t, node, res.Node.Spec.Hostname)
address := leafNodeAddress
if res.Cluster == s.root.Config.Auth.ClusterName.GetName() {
address = rootNodeAddress
}
require.Equal(t, address.String(), results[0].Node.Spec.Addr)
}
},
},
}
tshHome, _ := mustLoginLegacy(t, s)
for _, test := range testCases {
t.Run(test.description, func(t *testing.T) {
stdout := &bytes.Buffer{}
err := Run(context.Background(), test.command, setHomePath(tshHome), setOverrideStdout(stdout))
require.NoError(t, err)
test.assertion(t, stdout.Bytes())
})
}
}
// create a new local agent key ring and serve it on $SSH_AUTH_SOCK for tests.
func createAgent(t *testing.T) (agent.ExtendedAgent, string) {
t.Helper()
sockDir := "test"
sockName := "agent.sock"
keyring, ok := agent.NewKeyring().(agent.ExtendedAgent)
require.True(t, ok)
agentServer := sshagent.NewServer(sshagent.NewStaticClientGetter(keyring))
// Start the SSH agent.
err := agentServer.ListenUnixSocket(sockDir, sockName, nil)
require.NoError(t, err)
go agentServer.Serve()
t.Cleanup(func() {
agentServer.Close()
})
t.Setenv(teleport.SSHAuthSock, agentServer.Path)
return keyring, agentServer.Path
}
func disableAgent(t *testing.T) {
t.Helper()
t.Setenv(teleport.SSHAuthSock, "")
}
func (s *suite) setMockSSOLogin(t *testing.T) CliOption {
return setMockSSOLogin(s.root.GetAuthServer(), s.user, s.connector.GetName())
}
// deprecated: Use mustLogin instead which requires migrating from newTestSuite to
// tools/teleport/testenv.NewTeleportProcess.
func mustLoginLegacy(t *testing.T, s *suite, args ...string) (tshHome, kubeConfig string) {
tshHome = t.TempDir()
kubeConfig = filepath.Join(t.TempDir(), teleport.KubeConfigFile)
args = append([]string{
"login",
"--insecure",
"--debug",
"--proxy", s.root.Config.Proxy.WebAddr.String(),
}, args...)
err := Run(context.Background(), args,
s.setMockSSOLogin(t),
setHomePath(tshHome),
setKubeConfigPath(kubeConfig),
)
require.NoError(t, err, trace.DebugReport(err))
return
}
// login with new temp tshHome and set it in Env. This is useful
// when running "ssh" commands with a tsh "ProxyCommand".
// deprecated: Create a new helper that depends on mustLogin instead which requires migrating from
// newTestSuite to tools/teleport/testenv.NewTeleportProcess.
func mustLoginSetEnvLegacy(t *testing.T, s *suite, args ...string) (tshHome, kubeConfig string) {
tshHome, kubeConfig = mustLoginLegacy(t, s, args...)
t.Setenv(types.HomeEnvVar, tshHome)
return
}
func mustLoginIdentity(t *testing.T, s *suite) string {
identityFile := filepath.Join(t.TempDir(), "identity.pem")
mustLoginLegacy(t, s, "--out", identityFile)
return identityFile
}
func mustGetOpenSSHConfigFile(t *testing.T) string {
var buff bytes.Buffer
err := Run(context.Background(), []string{
"config",
}, setCopyStdout(&buff))
require.NoError(t, err)
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "ssh_config")
err = os.WriteFile(configPath, buff.Bytes(), 0o600)
require.NoError(t, err)
return configPath
}
func runOpenSSHCommand(t *testing.T, configFile string, sshConnString string, port int, args ...string) error {
ss := []string{
"-F", configFile,
"-y",
"-p", strconv.Itoa(port),
"-o", "StrictHostKeyChecking=no",
sshConnString,
}
ss = append(ss, args...)
sshPath, err := exec.LookPath("ssh")
require.NoError(t, err)
_, agentPath := createAgent(t)
cmd := exec.Command(sshPath, ss...)
cmd.Env = []string{
fmt.Sprintf("%s=1", tshBinMainTestEnv),
fmt.Sprintf("SSH_AUTH_SOCK=%s", agentPath),
fmt.Sprintf("PATH=%s", filepath.Dir(sshPath)),
fmt.Sprintf("%s=%s", types.HomeEnvVar, os.Getenv(types.HomeEnvVar)),
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stdout
return trace.Wrap(cmd.Run())
}
func mustRunOpenSSHCommand(t *testing.T, configFile string, sshConnString string, port int, args ...string) {
err := retryutils.RetryStaticFor(time.Second*10, time.Millisecond*500, func() error {
err := runOpenSSHCommand(t, configFile, sshConnString, port, args...)
return trace.Wrap(err)
})
require.NoError(t, err)
}
func mustFailToRunOpenSSHCommand(t *testing.T, configFile string, sshConnString string, port int, args ...string) {
err := runOpenSSHCommand(t, configFile, sshConnString, port, args...)
require.Error(t, err)
}
func mustFindFailedNodeLoginAttempt(t *testing.T, s *suite, nodeLogin string) {
err := retryutils.RetryStaticFor(5*time.Second, 50*time.Millisecond, func() error {
now := time.Now()
ctx := context.Background()
es, _, err := s.root.GetAuthServer().SearchEvents(ctx, events.SearchEventsRequest{
From: now.Add(-time.Hour),
To: now.Add(time.Hour),
Order: types.EventOrderDescending,
})
if err != nil {
return trace.Wrap(err)
}
for _, e := range es {
if e.GetCode() == events.AuthAttemptFailureCode && e.(*apievents.AuthAttempt).Login == nodeLogin {
return nil
}
}
return fmt.Errorf("failed to find AuthAttemptFailureCode event (0/%d events matched)", len(es))
})
require.NoError(t, err)
}
func TestFormatCommand(t *testing.T) {
t.Parallel()
setEnv := func(command *exec.Cmd, envs ...string) *exec.Cmd {
command.Env = append(command.Env, envs...)
return command
}
tests := []struct {
name string
cmd *exec.Cmd
want string
}{
{
name: "simple command",
cmd: exec.Command("echo", "hello"),
want: "echo hello",
},
{
name: "whitespace arguments",
cmd: exec.Command("echo", "hello world", "return\n\r", `1
2
3`),
want: "echo \"hello world\" \"return\n\r\" \"1\n2\n3\"",
},
{
name: "args and env",
cmd: setEnv(exec.Command("echo", "hello"), "DEBUG=1", "RUN=YES"),
want: "DEBUG=1 RUN=YES echo hello",
},
{
name: "args, whitespace and env",
cmd: setEnv(exec.Command("echo", "hello\"\nworld"), "DEBUG=1", "RUN=YES"),
want: "DEBUG=1 RUN=YES echo \"hello\\\"\nworld\"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, formatCommand(tt.cmd))
})
}
}
func Test_chooseProxyCommandTemplate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
commands []dbcmd.CommandAlternative
randomPort bool
wantTemplate *template.Template
wantTemplateArgs map[string]any
wantOutput string
}{
{
name: "single command",
commands: []dbcmd.CommandAlternative{
{
Description: "default",
Command: exec.Command("echo", "hello world"),
},
},
wantTemplate: dbProxyAuthTpl,
wantTemplateArgs: map[string]any{"command": "echo \"hello world\""},
wantOutput: `Started authenticated tunnel for the MySQL database "mydb" in cluster "mycluster" on 127.0.0.1:64444.
Teleport Connect is a desktop app that can manage database proxies for you.
Learn more at https://goteleport.com/docs/connect-your-client/teleport-connect/#connecting-to-a-database
Use the following command to connect to the database or to the address above using other database GUI/CLI clients:
$ echo "hello world"
`,
},
{
name: "multiple commands",
commands: []dbcmd.CommandAlternative{
{
Description: "default",
Command: exec.Command("echo", "hello world"),
},
{
Description: "alternative",
Command: exec.Command("echo", "goodbye world"),
},
},
wantTemplate: dbProxyAuthMultiTpl,
wantTemplateArgs: map[string]any{
"commands": []templateCommandItem{
{Description: "default", Command: "echo \"hello world\""},
{Description: "alternative", Command: "echo \"goodbye world\""},
},
},
wantOutput: `Started authenticated tunnel for the MySQL database "mydb" in cluster "mycluster" on 127.0.0.1:64444.
Teleport Connect is a desktop app that can manage database proxies for you.
Learn more at https://goteleport.com/docs/connect-your-client/teleport-connect/#connecting-to-a-database
Use one of the following commands to connect to the database or to the address above using other database GUI/CLI clients:
* default:
$ echo "hello world"
* alternative:
$ echo "goodbye world"
`,
},
{
name: "single command, random port",
commands: []dbcmd.CommandAlternative{
{
Description: "default",
Command: exec.Command("echo", "hello world"),
},
},
randomPort: true,
wantTemplate: dbProxyAuthTpl,
wantTemplateArgs: map[string]any{"command": "echo \"hello world\""},
wantOutput: `Started authenticated tunnel for the MySQL database "mydb" in cluster "mycluster" on 127.0.0.1:64444.
To avoid port randomization, you can choose the listening port using the --port flag.
Teleport Connect is a desktop app that can manage database proxies for you.
Learn more at https://goteleport.com/docs/connect-your-client/teleport-connect/#connecting-to-a-database
Use the following command to connect to the database or to the address above using other database GUI/CLI clients:
$ echo "hello world"
`,
},
{
name: "multiple commands, random port",
commands: []dbcmd.CommandAlternative{
{
Description: "default",
Command: exec.Command("echo", "hello world"),
},
{
Description: "alternative",
Command: exec.Command("echo", "goodbye world"),
},
},
randomPort: true,
wantTemplate: dbProxyAuthMultiTpl,
wantTemplateArgs: map[string]any{
"commands": []templateCommandItem{
{Description: "default", Command: "echo \"hello world\""},
{Description: "alternative", Command: "echo \"goodbye world\""},
},
},
wantOutput: `Started authenticated tunnel for the MySQL database "mydb" in cluster "mycluster" on 127.0.0.1:64444.
To avoid port randomization, you can choose the listening port using the --port flag.
Teleport Connect is a desktop app that can manage database proxies for you.
Learn more at https://goteleport.com/docs/connect-your-client/teleport-connect/#connecting-to-a-database
Use one of the following commands to connect to the database or to the address above using other database GUI/CLI clients:
* default:
$ echo "hello world"
* alternative:
$ echo "goodbye world"
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
templateArgs := map[string]any{}
tpl := chooseProxyCommandTemplate(templateArgs, tt.commands, &databaseInfo{})
require.Equal(t, tt.wantTemplate, tpl)
require.Equal(t, tt.wantTemplateArgs, templateArgs)
// test resulting template
templateArgs["database"] = "mydb"
templateArgs["cluster"] = "mycluster"
templateArgs["address"] = "127.0.0.1:64444"
templateArgs["type"] = defaults.ReadableDatabaseProtocol(defaults.ProtocolMySQL)
templateArgs["randomPort"] = tt.randomPort
buf := new(bytes.Buffer)
err := tpl.Execute(buf, templateArgs)
require.NoError(t, err)
require.Equal(t, tt.wantOutput, buf.String())
})
}
}
type fakeAWSAppInfo struct {
forwardProxyAddr string
}
func (f fakeAWSAppInfo) GetAppName() string {
return "fake-aws-app"
}
func (f fakeAWSAppInfo) GetEnvVars() (map[string]string, error) {
envVars := map[string]string{
"AWS_ACCESS_KEY_ID": "FAKE_ID",
"AWS_SECRET_ACCESS_KEY": "FAKE_KEY",
"AWS_CA_BUNDLE": "FAKE_CA_BUNDLE_PATH",
}
if f.forwardProxyAddr != "" {
envVars["HTTPS_PROXY"] = "http://" + f.forwardProxyAddr
}
return envVars, nil
}
func (f fakeAWSAppInfo) GetEndpointURL() string {
return "https://127.0.0.1:12345"
}
func (f fakeAWSAppInfo) GetForwardProxyAddr() string {
return f.forwardProxyAddr
}
func TestPrintProxyAWSTemplate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputCLIConf *CLIConf
inputAWSApp awsAppInfo
wantSnippets []string
}{
{
name: "HTTPS_PROXY mode",
inputCLIConf: &CLIConf{
Format: envVarDefaultFormat(),
},
inputAWSApp: fakeAWSAppInfo{
forwardProxyAddr: "127.0.0.1:8888",
},
wantSnippets: []string{
"127.0.0.1:8888",
"Use the following credentials and HTTPS proxy setting to connect to the proxy",
},
},
{
name: "athena-odbc",
inputCLIConf: &CLIConf{
Format: awsProxyFormatAthenaODBC,
},
inputAWSApp: fakeAWSAppInfo{
forwardProxyAddr: "127.0.0.1:8888",
},
wantSnippets: []string{
"DRIVER=Simba Amazon Athena ODBC Connector;",
"UseProxy=1;ProxyScheme=http;ProxyHost=127.0.0.1;ProxyPort=8888;",
},
},
{
name: "athena-jdbc",
inputCLIConf: &CLIConf{
Format: awsProxyFormatAthenaJDBC,
},
inputAWSApp: fakeAWSAppInfo{
forwardProxyAddr: "127.0.0.1:8888",
},
wantSnippets: []string{
"jdbc:awsathena:",
"ProxyHost=127.0.0.1;ProxyPort=8888;",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
buf := new(bytes.Buffer)
test.inputCLIConf.OverrideStdout = buf
require.NoError(t, printProxyAWSTemplate(test.inputCLIConf, test.inputAWSApp))
for _, wantSnippet := range test.wantSnippets {
require.Contains(t, buf.String(), wantSnippet)
}
})
}
}
func TestCheckProxyAWSFormatCompatibility(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input *CLIConf
checkError require.ErrorAssertionFunc
}{
{
name: "default format is supported in HTTPS_PROXY mode",
input: &CLIConf{
Format: envVarDefaultFormat(),
},
checkError: require.NoError,
},
{
name: "endpoint URL mode is not supported",
input: &CLIConf{
Format: envVarDefaultFormat(),
AWSEndpointURLMode: true,
},
checkError: require.Error,
},
{
name: "athena-odbc is supported in HTTPS_PROXY mode",
input: &CLIConf{
Format: awsProxyFormatAthenaODBC,
},
checkError: require.NoError,
},
{
name: "athena-jdbc is supported in HTTPS_PROXY mode",
input: &CLIConf{
Format: awsProxyFormatAthenaJDBC,
},
checkError: require.NoError,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.checkError(t, checkProxyAWSFormatCompatibility(test.input))
})
}
}
// TestProxyAppWithIdentity ensures `tsh -i <identity> proxy app` works
// correctly given a Machine ID-style identity with a valid RouteToApp.
func TestProxyAppWithIdentity(t *testing.T) {
disableAgent(t)
ctx := context.Background()
const (
clusterName = "root"
appName = "rootapp"
userName = "admin"
)
appServer := testserver.StartDummyHTTPServer(appName)
t.Cleanup(appServer.Close)
rootServerOpts := []testserver.TestServerOptFunc{
testserver.WithClusterName(clusterName),
testserver.WithTestApp(appName, appServer.URL),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
}),
}
process, err := testserver.NewTeleportProcess(t.TempDir(), rootServerOpts...)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, process.Close())
require.NoError(t, process.Wait())
})
authServer := process.GetAuthServer()
// create admin role and user.
adminRole, err := types.NewRole(userName, types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{types.Wildcard},
Verbs: []string{types.Wildcard},
},
},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
require.NoError(t, err)
adminRole, err = authServer.CreateRole(ctx, adminRole)
require.NoError(t, err)
user, err := types.NewUser(userName)
user.SetRoles([]string{adminRole.GetName()})
require.NoError(t, err)
_, err = authServer.CreateUser(ctx, user)
require.NoError(t, err)
// create an app session for the user
userState, err := authServer.GetUserOrLoginState(ctx, userName)
require.NoError(t, err)
accessInfo := services.AccessInfoFromUserState(userState)
ws, err := authServer.CreateAppSessionFromReq(ctx, auth.NewAppSessionRequest{
NewWebSessionRequest: auth.NewWebSessionRequest{
User: userName,
Roles: accessInfo.Roles,
Traits: accessInfo.Traits,
SessionTTL: time.Hour,
},
PublicAddr: fmt.Sprintf("%s.%s", appName, clusterName),
ClusterName: clusterName,
})
require.NoError(t, err)
key, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
require.NoError(t, err)
privateKey, err := keys.NewPrivateKey(key)
require.NoError(t, err)
// Identity files only support a single key for SSH/TLS
keyRing := client.NewKeyRing(privateKey, privateKey)
keyRing.ClusterName = clusterName
// generate user certs with a RouteToApp. note that unlike certs generated
// with `tsh app login`, this is intended to match Machine ID-type certs
// which are not usage restricted to apps only; this is required for tsh to
// make other auth API calls beyond just accessing the app.
tlsPub, err := privateKey.MarshalTLSPublicKey()
require.NoError(t, err)
sshCert, tlsCert, err := authServer.GenerateUserTestCertsWithContext(ctx, auth.GenerateUserTestCertsRequest{
SSHPubKey: privateKey.MarshalSSHPublicKey(),
TLSPubKey: tlsPub,
Username: userName,
TTL: time.Hour,
Compatibility: constants.CertificateFormatStandard,
RouteToCluster: clusterName,
AppName: appName,
AppSessionID: ws.GetName(),
})
require.NoError(t, err)
keyRing.Cert = sshCert
keyRing.TLSCert = tlsCert
hostCAs, err := authServer.GetCertAuthorities(context.Background(), types.HostCA, false)
require.NoError(t, err)
keyRing.TrustedCerts = authclient.AuthoritiesToTrustedCerts(hostCAs)
idPath := filepath.Join(t.TempDir(), "identity")
_, err = identityfile.Write(context.Background(), identityfile.WriteConfig{
OutputPath: idPath,
KeyRing: keyRing,
Format: identityfile.FormatFile,
})
require.NoError(t, err)
port := ports.Pop()
tshArgs := []string{
"--debug",
"--insecure",
"--identity", idPath,
"--proxy", process.Config.Proxy.WebAddr.Addr,
"proxy", "app", appName,
"--port", port,
}
testutils.RunTestBackgroundTask(ctx, t, &testutils.TestBackgroundTask{
Name: "tsh proxy app",
Task: func(ctx context.Context) error {
return Run(ctx, tshArgs)
},
})
err = retryutils.RetryStaticFor(5*time.Second, 50*time.Millisecond, func() error {
r, err := http.Get(fmt.Sprintf("http://localhost:%s", port))
if err != nil {
return err
}
defer r.Body.Close()
if r.StatusCode != 200 {
return trace.ReadError(r.StatusCode, nil)
}
return nil
})
require.NoError(t, err, "no proxied app request succeeded")
}
func TestProxyAppMultiPort(t *testing.T) {
disableAgent(t)
ctx := context.Background()
const (
clusterName = "root"
appName = "multi-port-app"
userName = "admin"
accessRoleName = "access"
)
fooServerURL := startDummyHTTPServer(t, "foo")
barServerURL := startDummyHTTPServer(t, "bar")
fooServerPort := mustGetPort(t, fooServerURL)
barServerPort := mustGetPort(t, barServerURL)
user, err := types.NewUser(userName)
user.SetRoles([]string{accessRoleName})
require.NoError(t, err)
connector := mockConnector(t)
rootServerOpts := []testserver.TestServerOptFunc{
testserver.WithBootstrap(connector, user),
testserver.WithClusterName(clusterName),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
cfg.Apps = servicecfg.AppsConfig{
Enabled: true,
Apps: []servicecfg.App{{
Name: appName,
URI: "tcp://localhost",
TCPPorts: []servicecfg.PortRange{
servicecfg.PortRange{Port: fooServerPort},
servicecfg.PortRange{Port: barServerPort},
},
}},
}
}),
}
process, err := testserver.NewTeleportProcess(t.TempDir(), rootServerOpts...)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, process.Close())
require.NoError(t, process.Wait())
})
tshHome, _ := mustLogin(t, process, user, connector.GetName())
// tsh proxy app seems to not handle multiple concurrent invocations well. Because of that, the
// test needs to first set up a local proxy, verify it and only then set up another one.
fooProxyPort := ports.Pop()
fooTshArgs := []string{
"--debug",
"--insecure",
"--proxy", process.Config.Proxy.WebAddr.Addr,
"proxy", "app", appName,
"--port", net.JoinHostPort(fooProxyPort, strconv.Itoa(fooServerPort)),
}
testutils.RunTestBackgroundTask(ctx, t, &testutils.TestBackgroundTask{
Name: "tsh proxy app (foo)",
Task: func(ctx context.Context) error {
return Run(ctx, fooTshArgs, setHomePath(tshHome))
},
})
mustDialLocalAppProxy(t, fooProxyPort, "foo")
fooNoTargetPortProxyPort := ports.Pop()
fooNoTargetPortTshArgs := []string{
"--debug",
"--insecure",
"--proxy", process.Config.Proxy.WebAddr.Addr,
"proxy", "app", appName,
"--port", fooNoTargetPortProxyPort, // No target port.
}
testutils.RunTestBackgroundTask(ctx, t, &testutils.TestBackgroundTask{
Name: "tsh proxy app (foo no target port)",
Task: func(ctx context.Context) error {
return Run(ctx, fooNoTargetPortTshArgs, setHomePath(tshHome))
},
})
// If there's no target port, the connections should still be routed to the first TCP port.
mustDialLocalAppProxy(t, fooNoTargetPortProxyPort, "foo")
barProxyPort := ports.Pop()
barTshArgs := []string{
"--debug",
"--insecure",
"--proxy", process.Config.Proxy.WebAddr.Addr,
"proxy", "app", appName,
"--port", net.JoinHostPort(barProxyPort, strconv.Itoa(barServerPort)),
}
testutils.RunTestBackgroundTask(ctx, t, &testutils.TestBackgroundTask{
Name: "tsh proxy app (bar)",
Task: func(ctx context.Context) error {
return Run(ctx, barTshArgs, setHomePath(tshHome))
},
})
mustDialLocalAppProxy(t, barProxyPort, "bar")
}
// mustGetPort extracts the port out of the URL returned by functions such as startDummyHTTPServer.
func mustGetPort(t *testing.T, rawURL string) int {
t.Helper()
url, err := url.Parse(rawURL)
require.NoError(t, err)
_, portString, err := net.SplitHostPort(url.Host)
require.NoError(t, err)
port, err := strconv.Atoi(portString)
require.NoError(t, err)
return port
}
func mustLogin(t *testing.T, s *service.TeleportProcess, user types.User, connectorName string, args ...string) (tshHome, kubeConfig string) {
t.Helper()
tshHome = t.TempDir()
kubeConfig = filepath.Join(t.TempDir(), teleport.KubeConfigFile)
args = append([]string{
"login",
"--insecure",
"--debug",
"--proxy", s.Config.Proxy.WebAddr.String(),
}, args...)
err := Run(context.Background(), args,
setMockSSOLogin(s.GetAuthServer(), user, connectorName),
setHomePath(tshHome),
setKubeConfigPath(kubeConfig),
)
require.NoError(t, err, trace.DebugReport(err))
return
}
// mustDialLocalAppProxy verifies that a local app proxy for an app backed by startDummyHTTPServer
// returns the expected HTTP response.
func mustDialLocalAppProxy(t *testing.T, port string, expectedName string) {
t.Helper()
require.EventuallyWithT(t, func(t *assert.CollectT) {
r, err := http.Get(fmt.Sprintf("http://localhost:%s", port))
require.NoError(t, err)
defer r.Body.Close()
require.Equal(t, 200, r.StatusCode)
require.Equal(t, expectedName, r.Header.Get("Server"), "the response header \"Server\" does not have the expected value")
}, 5*time.Second, 50*time.Millisecond)
}
func Test_checkProxyMCPCompatibility(t *testing.T) {
tests := []struct {
name string
command string
appURI string
checkResult require.ErrorAssertionFunc
}{
{
name: "streamable HTTP allowed for tsh proxy app",
command: "proxy app",
appURI: "mcp+http://example.com/mcp",
checkResult: require.NoError,
},
{
name: "streamable HTTP allowed for tsh proxy mcp",
command: "proxy mcp",
appURI: "mcp+http://example.com/mcp",
checkResult: require.NoError,
},
{
name: "unsupported transport fails for tsh proxy app",
command: "proxy app",
appURI: "mcp+sse+http://example.com/sse",
checkResult: require.Error,
},
{
name: "unsupported transport fails for tsh proxy mcp",
command: "proxy mcp",
appURI: "mcp+sse+http://example.com/sse",
checkResult: require.Error,
},
{
name: "regular app allowed for tsh proxy app",
command: "proxy app",
appURI: "http://example.com",
checkResult: require.NoError,
},
{
name: "regular app fails for tsh proxy mcp",
command: "proxy mcp",
appURI: "http://example.com",
checkResult: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app, err := types.NewAppV3(types.Metadata{
Name: t.Name(),
}, types.AppSpecV3{
URI: tt.appURI,
})
require.NoError(t, err)
tt.checkResult(t, checkProxyMCPCompatibility(tt.command, app))
})
}
}