Files
teleport/tool/tsh/common/tsh_test.go
T
Edoardo Spadolini 825b23049e session-helper: move srv.RunAndExit to session/reexec (#65263)
* Move the reexec parts of lib/srv to session/reexec

* Update references to the moved bits of lib/srv

* Avoid testutils in session/reexec

* Shuffle some constants around to avoid imports in session/reexec

* Vendor in the relevant parts of logutils in session/reexec

* Remove unnecessary symlink checks when opening files

* Inline the last two things from lib/utils in session/reexec

* Inline moved constants

* Deprecate group name consts and fix missed inlines

* Add missing session/reexec.TestMain with reexec check

* Reuse existing constants from the log constants package in session

* Fix broken godoc link
2026-04-08 09:40:52 +00:00

8532 lines
242 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"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/url"
"os"
"os/exec"
"os/user"
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/ghodss/yaml"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
otlp "go.opentelemetry.io/proto/otlp/trace/v1"
"golang.org/x/crypto/ssh"
yamlv2 "gopkg.in/yaml.v2"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/breaker"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/constants"
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/profile"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/accesslist"
"github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/types/wrappers"
apiutils "github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/api/utils/keypaths"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/api/utils/keys/hardwarekey"
"github.com/gravitational/teleport/api/utils/prompt"
"github.com/gravitational/teleport/entitlements"
"github.com/gravitational/teleport/integration/kube"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/auth/mocku2f"
wancli "github.com/gravitational/teleport/lib/auth/webauthncli"
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/client/identityfile"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/cryptosuites/cryptosuitestest"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/fixtures"
"github.com/gravitational/teleport/lib/kube/kubeconfig"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/modules/modulestest"
"github.com/gravitational/teleport/lib/observability/tracing"
"github.com/gravitational/teleport/lib/service"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/utils/log/logtest"
"github.com/gravitational/teleport/lib/utils/testutils"
"github.com/gravitational/teleport/lib/utils/testutils/golden"
"github.com/gravitational/teleport/session/networking/x11"
"github.com/gravitational/teleport/session/reexec"
"github.com/gravitational/teleport/session/reexec/reexecconstants"
"github.com/gravitational/teleport/tool/common"
testserver "github.com/gravitational/teleport/tool/teleport/testenv"
)
const (
mockHeadlessPassword = "password1234"
staticToken = "test-static-token"
// tshBinMainTestEnv allows to execute tsh main function from test binary.
tshBinMainTestEnv = "TSH_BIN_MAIN_TEST"
// tshBinMainTestOneshotEnv allows child processes of a tsh reexec process
// to call teleport instead of tsh to support 'tsh ssh'.
tshBinMainTestOneshotEnv = "TSH_BIN_MAIN_TEST_ONESHOT"
// tshBinMockHeadlessAddr allows tests to mock headless auth when the
// test binary is re-executed.
tshBinMockHeadlessAddrEnv = "TSH_BIN_MOCK_HEADLESS_ADDR"
)
var ports utils.PortList
const initTestSentinel = "init_test"
func TestMain(m *testing.M) {
handleReexec()
var err error
ports, err = utils.GetFreeTCPPorts(5000, utils.PortStartingNumber)
if err != nil {
panic(fmt.Sprintf("failed to allocate tcp ports for tests: %v", err))
}
modules.SetModules(&cliModules{})
modules.SetInsecureTestMode(true)
logtest.InitLogger(testing.Verbose)
ctx, cancel := context.WithCancel(context.Background())
cryptosuitestest.PrecomputeRSAKeys(ctx)
exitCode := m.Run()
cancel()
os.Exit(exitCode)
}
func BenchmarkInit(b *testing.B) {
if testing.Short() {
b.Skip("skipping heavy benchmark")
}
executable, err := os.Executable()
require.NoError(b, err)
for b.Loop() {
cmd := exec.Command(executable, initTestSentinel)
err := cmd.Run()
assert.NoError(b, err)
}
}
func handleReexec() {
if slices.Contains(os.Args, initTestSentinel) {
os.Exit(0)
}
var runOpts []CliOption
// Allows mock headless auth to be implemented when the test binary
// is re-executed.
if addr := os.Getenv(tshBinMockHeadlessAddrEnv); addr != "" {
runOpts = append(runOpts, func(c *CLIConf) error {
c.MockHeadlessLogin = func(ctx context.Context, keyRing *client.KeyRing) (*authclient.CLILoginResponse, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, trace.Wrap(err, "dialing mock headless server")
}
defer conn.Close()
// send the server the SSH public key
_, err = conn.Write(keyRing.SSHPrivateKey.MarshalSSHPublicKey())
if err != nil {
return nil, trace.Wrap(err, "writing public key to mock headless server")
}
// send 0 byte as key delimiter
if _, err := conn.Write([]byte{0}); err != nil {
return nil, trace.Wrap(err, "writing delimiter")
}
// send the server the TLS public key
tlsPub, err := keyRing.TLSPrivateKey.MarshalTLSPublicKey()
if err != nil {
return nil, trace.Wrap(err)
}
_, err = conn.Write(tlsPub)
if err != nil {
return nil, trace.Wrap(err, "writing public key to mock headless server")
}
// send 0 byte as key delimiter
if _, err := conn.Write([]byte{0}); err != nil {
return nil, trace.Wrap(err, "writing delimiter")
}
// read and decode response from server
reply, err := io.ReadAll(conn)
if err != nil {
return nil, trace.Wrap(err, "reading reply from mock headless server")
}
var loginResp authclient.CLILoginResponse
if err := json.Unmarshal(reply, &loginResp); err != nil {
return nil, trace.Wrap(err, "decoding reply from mock headless server")
}
return &loginResp, nil
}
return nil
})
}
// Re-exec tsh commands. Needed for:
// - Tests that generate OpenSSH config by tsh config command where
// tsh proxy ssh command is used as ProxyCommand.
// - Fork after authentication.
if os.Getenv(tshBinMainTestEnv) != "" || (len(os.Args) >= 2 && os.Args[1] == "--fork-signal-fd") {
if os.Getenv(tshBinMainTestOneshotEnv) != "" {
// unset this env var so child processes started by 'tsh ssh'
// will be executed correctly below.
if err := os.Unsetenv(tshBinMainTestEnv); err != nil {
panic(fmt.Sprintf("failed to unset env var: %v", err))
}
}
err := Run(context.Background(), os.Args[1:], runOpts...)
if err != nil {
var exitError *common.ExitCodeError
if errors.As(err, &exitError) {
os.Exit(exitError.Code)
}
utils.FatalError(err)
}
os.Exit(0)
}
// Re-exec teleport commands. Used to test tsh ssh command.
if reexec.IsReexec() {
reexec.RunAndExit(os.Args[1])
}
}
type cliModules struct{}
func (p *cliModules) GenerateLongTermResourceGrouping(_ context.Context, _ modules.AccessResourcesGetter, _ types.AccessRequest) (*types.LongTermResourceGrouping, error) {
return &types.LongTermResourceGrouping{}, nil
}
func (p *cliModules) GenerateAccessRequestPromotions(_ context.Context, _ modules.AccessResourcesGetter, _ types.AccessRequest) (*types.AccessRequestAllowedPromotions, error) {
return &types.AccessRequestAllowedPromotions{}, nil
}
func (p *cliModules) GenerateAccessRequestSuggestedReviewers(_ context.Context, _ modules.AccessResourcesGetter, _ types.AccessRequest) ([]string, error) {
return []string{}, nil
}
func (p *cliModules) GetSuggestedAccessLists(ctx context.Context, _ *tlsca.Identity, _ modules.AccessListSuggestionClient, _ modules.AccessListAndMembersGetter, _ string) ([]*accesslist.AccessList, error) {
return []*accesslist.AccessList{}, nil
}
// BuildType returns build type (OSS or Enterprise)
func (p *cliModules) BuildType() string {
return "CLI"
}
// IsEnterpriseBuild returns false for [cliModules].
func (p *cliModules) IsEnterpriseBuild() bool {
return false
}
// IsOSSBuild returns false for [cliModules].
func (p *cliModules) IsOSSBuild() bool {
return false
}
// LicenseExpiry returns the expiry date of the enterprise license, if applicable.
func (p *cliModules) LicenseExpiry() time.Time {
return time.Time{}
}
// PrintVersion prints the Teleport version.
func (p *cliModules) PrintVersion() {
fmt.Printf("Teleport CLI\n")
}
// Features returns supported features
func (p *cliModules) Features() modules.Features {
return modules.Features{
Entitlements: map[entitlements.EntitlementKind]modules.EntitlementInfo{
entitlements.K8s: {Enabled: true},
entitlements.DB: {Enabled: true},
entitlements.App: {Enabled: true},
},
AdvancedAccessWorkflows: true,
AccessControls: true,
}
}
// IsBoringBinary checks if the binary was compiled with BoringCrypto.
func (p *cliModules) IsBoringBinary() bool {
return false
}
// AttestHardwareKey attests a hardware key.
func (p *cliModules) AttestHardwareKey(_ context.Context, _ any, _ *hardwarekey.AttestationStatement, _ crypto.PublicKey, _ time.Duration) (*keys.AttestationData, error) {
return nil, trace.NotFound("no attestation data for the given key")
}
func (p *cliModules) EnableRecoveryCodes() {
}
func (p *cliModules) EnablePlugins() {
}
func (p *cliModules) SetFeatures(f modules.Features) {
}
func (p *cliModules) EnableAccessGraph() {}
func (p *cliModules) EnableAccessMonitoring() {}
func TestAlias(t *testing.T) {
testExecutable, err := os.Executable()
require.NoError(t, err)
tests := []struct {
name string
aliases map[string]string
args []string
wantErr bool
validateOutput func(t *testing.T, output string)
}{
{
name: "loop",
aliases: map[string]string{
"loop": fmt.Sprintf("%v loop", testExecutable),
},
args: []string{"loop"},
wantErr: true,
validateOutput: func(t *testing.T, output string) {
require.Contains(t, output, "recursive alias \"loop\"; correct alias definition and try again")
},
},
{
name: "loop via other",
aliases: map[string]string{
"loop": fmt.Sprintf("%v loop", testExecutable),
"loop-call": fmt.Sprintf("%v loop", testExecutable),
},
args: []string{"loop-call"},
wantErr: true,
validateOutput: func(t *testing.T, output string) {
require.Contains(t, output, "recursive alias \"loop\"; correct alias definition and try again")
},
},
{
name: "r1 -> r2 -> r1",
aliases: map[string]string{
"r1": fmt.Sprintf("%v r2", testExecutable),
"r2": fmt.Sprintf("%v r1", testExecutable),
},
args: []string{"r2"},
wantErr: true,
validateOutput: func(t *testing.T, output string) {
require.Contains(t, output, "recursive alias \"r2\"; correct alias definition and try again")
},
},
{
name: "set default flag to command",
aliases: map[string]string{
"version": fmt.Sprintf("%v version --format=json", testExecutable),
},
args: []string{"version"},
wantErr: false,
validateOutput: func(t *testing.T, output string) {
require.Contains(t, output, `"version"`)
require.Contains(t, output, `"gitref"`)
require.Contains(t, output, `"runtime"`)
},
},
{
name: "default flag and alias",
aliases: map[string]string{
"version": fmt.Sprintf("%v version --format=json", testExecutable),
"v": fmt.Sprintf("%v version", testExecutable),
},
args: []string{"v"},
wantErr: false,
validateOutput: func(t *testing.T, output string) {
require.Contains(t, output, `"version"`)
require.Contains(t, output, `"gitref"`)
require.Contains(t, output, `"runtime"`)
},
},
{
name: "call external program, pass non-zero exit code",
aliases: map[string]string{
"ss": fmt.Sprintf("%v status", testExecutable),
"bb": fmt.Sprintf("bash -c '%v ss'", testExecutable),
},
args: []string{"bb"},
wantErr: true,
validateOutput: func(t *testing.T, output string) {
require.Contains(t, output, fmt.Sprintf("%vNot logged in", utils.Color(utils.Red, "ERROR: ")))
require.Contains(t, output, fmt.Sprintf("%vexit status 1", utils.Color(utils.Red, "ERROR: ")))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// make sure we have fresh configs for the tests:
// - new home
// - new global config
tmpHomePath := t.TempDir()
t.Setenv(types.HomeEnvVar, tmpHomePath)
t.Setenv(globalTshConfigEnvVar, filepath.Join(tmpHomePath, "tsh_global.yaml"))
// make the re-exec behave as `tsh` instead of test binary.
t.Setenv(tshBinMainTestEnv, "1")
// write config to use
config := &client.TSHConfig{Aliases: tt.aliases}
configBytes, err := yamlv2.Marshal(config)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpHomePath, "tsh_global.yaml"), configBytes, 0o777)
require.NoError(t, err)
// run command
cmd := exec.Command(testExecutable, tt.args...)
t.Logf("running command %v", cmd)
output, err := cmd.CombinedOutput()
t.Logf("executable output: %v", string(output))
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
tt.validateOutput(t, string(output))
})
}
}
// TestNoEnvVars checks if tsh is able to work without any env vars provided.
// This is important for VNet on macOS. When launchd starts VNet's launch daemon, it executes "tsh
// vnet-daemon" with only the following env vars available:
//
// XPC_SERVICE_NAME=com.goteleport.tshdev.vnetd
// PATH=/usr/bin:/bin:/usr/sbin:/sbin
// XPC_FLAGS=1
//
// …plus whatever is set in the launch daemon plist under the EnvironmentVariables key.
func TestNoEnvVars(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
testExecutable, err := os.Executable()
require.NoError(t, err)
// Execute an actual command and not just `tsh help` which goes through a different code path.
cmd := exec.CommandContext(ctx, testExecutable, "version", "--client")
// Run the command with no env vars except tshBinMainTestEnv, otherwise the test would hang.
cmd.Env = []string{fmt.Sprintf("%s=1", tshBinMainTestEnv)}
t.Logf("running command %v", cmd)
output, err := cmd.CombinedOutput()
t.Logf("executable output: %v", string(output))
// By checking the ctx error together with err, the error report will include "context deadline
// exceeded" if the command doesn't complete within the timeout. Otherwise the error would be just
// "signal: killed".
require.NoError(t, trace.NewAggregate(err, ctx.Err()))
}
// TestDefaultPrintUsage verifies that the main `kingpin.Application` parser has not been
// previously terminated, and that it correctly prints the usage message when using the
// global `--help` flag or the `help` command, and both are identical.
func TestDefaultPrintUsage(t *testing.T) {
t.Parallel()
testExecutable, err := os.Executable()
require.NoError(t, err)
ctx := context.Background()
cmd := exec.CommandContext(ctx, testExecutable, "version", "--help")
cmd.Env = []string{fmt.Sprintf("%s=1", tshBinMainTestEnv), "TELEPORT_TOOLS_VERSION=off"}
flagOutput, err := cmd.CombinedOutput()
require.NoError(t, err)
require.Contains(t, string(flagOutput), "Print the tsh client and Proxy server versions for the current context")
cmd = exec.CommandContext(ctx, testExecutable, "help", "version")
cmd.Env = []string{fmt.Sprintf("%s=1", tshBinMainTestEnv), "TELEPORT_TOOLS_VERSION=off"}
commandOutput, err := cmd.CombinedOutput()
require.NoError(t, err)
require.Equal(t, string(flagOutput), string(commandOutput))
}
func TestFailedLogin(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
connector := mockConnector(t)
_, proxyProcess := makeTestServers(t, withBootstrap(connector))
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
// build a mock SSO login function to patch into tsh
loginFailed := trace.AccessDenied("login failed")
ssoLogin := func(ctx context.Context, connectorID string, keyRing *client.KeyRing, protocol string) (*authclient.SSHLoginResponse, error) {
return nil, loginFailed
}
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr.String(),
}, setHomePath(tmpHomePath), setMockSSOLoginCustom(ssoLogin, connector.GetName()))
require.ErrorIs(t, err, loginFailed)
}
func TestOIDCLogin(t *testing.T) {
tmpHomePath := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
// set up an initial role with `request_access: always` in order to
// trigger automatic post-login escalation.
populist, err := types.NewRole("populist", types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"dictator"},
},
},
Options: types.RoleOptions{
RequestAccess: types.RequestStrategyAlways,
},
})
require.NoError(t, err)
// empty role which serves as our escalation target
dictator, err := types.NewRole("dictator", types.RoleSpecV6{})
require.NoError(t, err)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"populist"})
connector := mockConnector(t)
motd := "MESSAGE_OF_THE_DAY_OIDC"
authProcess, proxyProcess := makeTestServers(t,
withBootstrap(populist, dictator, connector, alice),
withMOTD(t, motd),
)
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
// set up watcher to approve the automatic request in background
var didAutoRequest atomic.Bool
watcher, err := authServer.NewWatcher(ctx, types.Watch{
Kinds: []types.WatchKind{
{Kind: types.KindAccessRequest},
},
})
require.NoError(t, err)
// ensure that we observe init event prior to moving watcher to background
// goroutine (ensures watcher init does not race with request creation).
select {
case event := <-watcher.Events():
require.Equal(t, types.OpInit, event.Type)
case <-watcher.Done():
require.FailNow(t, "watcher closed unexpected", "err: %v", watcher.Error())
}
go func() {
select {
case event := <-watcher.Events():
if event.Type != types.OpPut {
panic(fmt.Sprintf("unexpected event type: %v\n", event))
}
err := authServer.SetAccessRequestState(ctx, types.AccessRequestUpdate{
RequestID: event.Resource.(types.AccessRequest).GetName(),
State: types.RequestState_APPROVED,
})
if err != nil {
panic(fmt.Sprintf("failed to approve request: %v", err))
}
didAutoRequest.Store(true)
case <-watcher.Done():
panic(fmt.Sprintf("watcher exited unexpectedly: %v", watcher.Error()))
}
}()
buf := bytes.NewBuffer([]byte{})
sc := bufio.NewScanner(buf)
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr.String(),
"--user", "alice", // explicitly use wrong name
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()), func(c *CLIConf) error {
c.overrideStderr = buf
c.SiteName = "localhost"
return nil
})
require.NoError(t, err)
// verify that auto-request happened
require.True(t, didAutoRequest.Load())
findMOTD(t, sc, motd)
// if we got this far, then tsh successfully registered name change from `alice` to
// `[email protected]`, since the correct name needed to be used for the access
// request to be generated.
}
// TestLoginRequestIDPrintsUpdatedProfile asserts that tsh login --request-id
// prints profile information that is up to date with the newly assumed access
// request.
func TestLoginRequestIDPrintsUpdatedProfile(t *testing.T) {
t.Parallel()
ctx := t.Context()
tmpHomePath := t.TempDir()
requester, err := types.NewRole("requester", types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"elevated"},
},
},
})
require.NoError(t, err)
elevated, err := types.NewRole("elevated", types.RoleSpecV6{})
require.NoError(t, err)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{requester.GetName()})
connector := mockConnector(t)
authProcess, proxyProcess := makeTestServers(t, withBootstrap(requester, elevated, connector, alice))
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
// Do an initial login without the access request.
loginOutput := &output{}
err = Run(ctx, []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
"--user", alice.GetName(),
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()), func(cf *CLIConf) error {
cf.OverrideStdout = loginOutput
return nil
})
require.NoError(t, err)
require.NotContains(t, loginOutput.String(), elevated.GetName())
// Create and approve and access request.
requestID := uuid.NewString()
req, err := types.NewAccessRequest(requestID, alice.GetName(), elevated.GetName())
require.NoError(t, err)
accessExpiry := time.Now().Add(time.Hour)
req.SetExpiry(accessExpiry)
req.SetAccessExpiry(accessExpiry)
err = authServer.CreateAccessRequest(ctx, req)
require.NoError(t, err)
err = authServer.SetAccessRequestState(ctx, types.AccessRequestUpdate{
RequestID: requestID,
State: types.RequestState_APPROVED,
})
require.NoError(t, err)
// Relogin with a approved access request.
requestLoginOutput := &output{}
err = Run(ctx, []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
"--request-id", requestID,
}, setHomePath(tmpHomePath), func(cf *CLIConf) error {
cf.OverrideStdout = requestLoginOutput
return nil
})
require.NoError(t, err)
// Assert that the output contains the ID of the active request and the
// name of the newly assumed role.
require.Regexp(t,
regexp.MustCompile(`Active requests:\s.*\b`+requestID+`\b`),
requestLoginOutput.String(),
)
require.Regexp(t,
regexp.MustCompile(`Roles:\s.*\belevated\b`),
requestLoginOutput.String(),
)
}
func findMOTD(t *testing.T, sc *bufio.Scanner, motd string) {
t.Helper()
for sc.Scan() {
if strings.Contains(sc.Text(), motd) {
return
}
}
require.Fail(t, "Failed to find %q MOTD in the logs", motd)
}
// TestLoginIdentityOut makes sure that "tsh login --out <ident>" command
// writes identity credentials to the specified path. It also supports
// specifying the output format via `--format=<format>`.
func TestLoginIdentityOut(t *testing.T) {
const kubeClusterName = "kubeTest"
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
authProcess, proxyProcess := makeTestServers(t, withBootstrap(connector, alice))
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
cluster, err := types.NewKubernetesClusterV3(types.Metadata{
Name: kubeClusterName,
Labels: map[string]string{},
},
types.KubernetesClusterSpecV3{},
)
require.NoError(t, err)
kubeServer, err := types.NewKubernetesServerV3FromCluster(cluster, kubeClusterName, kubeClusterName)
require.NoError(t, err)
_, err = authServer.UpsertKubernetesServer(context.Background(), kubeServer)
require.NoError(t, err)
cases := []struct {
name string
extraArgs []string
validationFunc func(t *testing.T, identityPath string)
requiresTLSRouting bool
}{
{
name: "write identity out",
validationFunc: func(t *testing.T, identityPath string) {
_, err := identityfile.KeyRingFromIdentityFile(identityPath, "proxy.example.com", "")
require.NoError(t, err)
},
},
{
name: "write identity in kubeconfig format with tls routing enabled",
extraArgs: []string{"--format", "kubernetes", "--kube-cluster", kubeClusterName},
validationFunc: func(t *testing.T, identityPath string) {
cfg, err := kubeconfig.Load(identityPath)
require.NoError(t, err)
kubeCtx := cfg.Contexts[cfg.CurrentContext]
require.NotNil(t, kubeCtx)
cluster := cfg.Clusters[kubeCtx.Cluster]
require.NotNil(t, cluster)
require.NotEmpty(t, cluster.TLSServerName)
},
requiresTLSRouting: true,
},
{
name: "write identity in kubeconfig format with tls routing disabled",
extraArgs: []string{"--format", "kubernetes", "--kube-cluster", kubeClusterName},
validationFunc: func(t *testing.T, identityPath string) {
cfg, err := kubeconfig.Load(identityPath)
require.NoError(t, err)
kubeCtx := cfg.Contexts[cfg.CurrentContext]
require.NotNil(t, kubeCtx)
cluster := cfg.Clusters[kubeCtx.Cluster]
require.NotNil(t, cluster)
require.Empty(t, cluster.TLSServerName)
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
identPath := filepath.Join(t.TempDir(), "ident")
if tt.requiresTLSRouting {
switchProxyListenerMode(t, authServer, types.ProxyListenerMode_Multiplex)
}
err = Run(context.Background(), append([]string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr.String(),
"--out", identPath,
}, tt.extraArgs...), setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()))
require.NoError(t, err)
tt.validationFunc(t, identPath)
})
}
}
// switchProxyListenerMode switches the proxy listener mode to the specified mode
// and schedules a reversion to the previous value once the sub-test completes.
func switchProxyListenerMode(t *testing.T, authServer *auth.Server, mode types.ProxyListenerMode) {
networkCfg, err := authServer.GetClusterNetworkingConfig(context.Background())
require.NoError(t, err)
prevValue := networkCfg.GetProxyListenerMode()
networkCfg.SetProxyListenerMode(mode)
_, err = authServer.UpsertClusterNetworkingConfig(context.Background(), networkCfg)
require.NoError(t, err)
t.Cleanup(func() {
networkCfg.SetProxyListenerMode(prevValue)
_, err = authServer.UpsertClusterNetworkingConfig(context.Background(), networkCfg)
require.NoError(t, err)
})
}
func TestRelogin(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
motd := "RELOGIN MOTD PRESENT"
authProcess, proxyProcess := makeTestServers(t,
withBootstrap(connector, alice),
withMOTD(t, motd),
)
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
buf := bytes.NewBuffer([]byte{})
sc := bufio.NewScanner(buf)
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr.String(),
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()), func(cf *CLIConf) error {
cf.overrideStderr = buf
return nil
})
require.NoError(t, err)
findMOTD(t, sc, motd)
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr.String(),
"localhost",
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()), func(cf *CLIConf) error {
cf.overrideStderr = buf
return nil
})
require.NoError(t, err)
findMOTD(t, sc, motd)
err = Run(context.Background(), []string{"logout"}, setHomePath(tmpHomePath),
func(cf *CLIConf) error {
cf.overrideStderr = buf
return nil
})
require.NoError(t, err)
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr.String(),
"localhost",
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()), func(cf *CLIConf) error {
cf.overrideStderr = buf
return nil
})
findMOTD(t, sc, motd)
require.NoError(t, err)
}
// Test when https:// is included in --proxy address
func TestIgnoreHTTPSPrefix(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
authProcess, proxyProcess := makeTestServers(t,
withBootstrap(connector, alice),
)
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
var buf bytes.Buffer
proxyAddress := "https://" + proxyAddr.String()
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddress,
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()), func(cf *CLIConf) error {
cf.overrideStderr = &buf
return nil
})
require.NoError(t, err)
}
func TestSwitchingProxies(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
connector := mockConnector(t)
// Connector need not be functional since we are going to mock the actual
// login operation.
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
auth1, proxy1 := makeTestServers(t,
withBootstrap(connector, alice),
)
auth2, proxy2 := makeTestServers(t,
withBootstrap(connector, alice),
)
authServer1 := auth1.GetAuthServer()
require.NotNil(t, authServer1)
proxyAddr1, err := proxy1.ProxyWebAddr()
require.NoError(t, err)
authServer2 := auth2.GetAuthServer()
require.NotNil(t, authServer2)
proxyAddr2, err := proxy2.ProxyWebAddr()
require.NoError(t, err)
// perform initial login to both proxies
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr1.String(),
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer1, alice, connector.GetName()))
require.NoError(t, err)
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr2.String(),
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer2, alice, connector.GetName()))
require.NoError(t, err)
// login again while both proxies are still valid and ensure it is successful without an SSO login provided
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr1.String(),
}, setHomePath(tmpHomePath))
require.NoError(t, err)
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr2.String(),
}, setHomePath(tmpHomePath))
require.NoError(t, err)
// logout
err = Run(context.Background(), []string{"logout"}, setHomePath(tmpHomePath))
require.NoError(t, err)
// after logging out, make sure that any attempt to log in without providing a valid login function fails
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
err = Run(ctx, []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr1.String(),
}, setHomePath(tmpHomePath))
require.Error(t, err)
err = Run(ctx, []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr2.String(),
}, setHomePath(tmpHomePath))
require.Error(t, err)
cancel()
}
// TestPrintNodesAsText verifies the expected behavior of printNodesAsText.
func TestPrintNodesAsText(t *testing.T) {
t.Parallel()
unscopedDirect := &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Name: "unscoped-direct-uuid",
Labels: map[string]string{
"key": "val",
},
},
Spec: types.ServerSpecV2{
Addr: "1.2.3.4:22",
Hostname: "unscoped-direct-name",
},
Version: types.V2,
}
unscopedTunnel := &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Name: "unscoped-tunnel-uuid",
},
Spec: types.ServerSpecV2{
UseTunnel: true,
Hostname: "unscoped-tunnel-name",
},
Version: types.V2,
}
scopedDirect := &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Name: "scoped-direct-uuid",
Labels: map[string]string{
"key1": "val1",
"key2": "val2",
},
},
Scope: "/staging/west",
Spec: types.ServerSpecV2{
Addr: "1.2.3.4:44",
Hostname: "scoped-direct-name",
},
Version: types.V2,
}
scopedTunnel := &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Name: "scoped-tunnel-uuid",
},
Scope: "/staging/west",
Spec: types.ServerSpecV2{
UseTunnel: true,
Hostname: "scoped-tunnel-name",
},
Version: types.V2,
}
tts := []struct {
name string
nodes []types.Server
verbose bool
}{
{
name: "non-verbose unscoped",
nodes: []types.Server{unscopedDirect, unscopedTunnel},
verbose: false,
},
{
name: "verbose unscoped",
nodes: []types.Server{unscopedDirect, unscopedTunnel},
verbose: true,
},
{
name: "non-verbose scoped",
nodes: []types.Server{scopedDirect, scopedTunnel},
verbose: false,
},
{
name: "verbose scoped",
nodes: []types.Server{scopedDirect, scopedTunnel},
verbose: true,
},
{
name: "mixed scoped and unscoped verbose",
nodes: []types.Server{unscopedDirect, scopedDirect, unscopedTunnel, scopedTunnel},
verbose: true,
},
}
for _, tt := range tts {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
printNodesAsText(&buf, tt.nodes, tt.verbose)
if golden.ShouldSet() {
golden.Set(t, buf.Bytes())
}
require.Equal(t, string(golden.Get(t)), buf.String())
})
}
}
func TestMakeClient(t *testing.T) {
t.Parallel()
var conf CLIConf
conf.HomePath = t.TempDir()
// empty config won't work:
tc, err := makeClient(&conf)
require.Nil(t, tc)
require.Error(t, err)
// minimal configuration (with defaults)
conf.Proxy = "proxy:3080"
conf.UserHost = "localhost"
conf.HomePath = t.TempDir()
// Create a empty profile so we don't ping proxy.
profile := &profile.Profile{
SSHProxyAddr: "proxy:3023",
WebProxyAddr: "proxy:3080",
}
err = conf.getClientStore().SaveProfile(profile, true)
require.NoError(t, err)
tc, err = makeClient(&conf)
require.NoError(t, err)
require.NotNil(t, tc)
// profile info should be loaded into client
require.Equal(t, "proxy:3023", tc.Config.SSHProxyAddr)
require.Equal(t, "proxy:3080", tc.Config.WebProxyAddr)
localUser, err := client.Username()
require.NoError(t, err)
require.Equal(t, localUser, tc.Config.HostLogin)
require.Equal(t, time.Duration(0), tc.Config.KeyTTL)
// specific configuration
conf.MinsToLive = 5
conf.UserHost = "root@localhost"
conf.NodePort = 46528
conf.LocalForwardPorts = []string{"80:remote:180"}
conf.DynamicForwardedPorts = []string{":8080"}
tc, err = makeClient(&conf)
require.NoError(t, err)
require.Equal(t, time.Minute*time.Duration(conf.MinsToLive), tc.Config.KeyTTL)
require.Equal(t, "root", tc.Config.HostLogin)
require.Equal(t, client.ForwardedPorts{
{
SrcIP: "127.0.0.1",
SrcPort: 80,
DestHost: "remote",
DestPort: 180,
},
}, tc.Config.LocalForwardPorts)
require.Equal(t, client.DynamicForwardedPorts{
{
SrcIP: "127.0.0.1",
SrcPort: 8080,
},
}, tc.Config.DynamicForwardedPorts)
// specific configuration with email like user
conf.MinsToLive = 5
conf.UserHost = "[email protected]@localhost"
conf.NodePort = 46528
conf.LocalForwardPorts = []string{"80:remote:180"}
conf.DynamicForwardedPorts = []string{":8080"}
conf.TSHConfig.ExtraHeaders = []client.ExtraProxyHeaders{
{Proxy: "proxy:3080", Headers: map[string]string{"A": "B"}},
{Proxy: "*roxy:3080", Headers: map[string]string{"C": "D"}},
{Proxy: "*hello:3080", Headers: map[string]string{"E": "F"}}, // shouldn't get included
}
tc, err = makeClient(&conf)
require.NoError(t, err)
require.Equal(t, time.Minute*time.Duration(conf.MinsToLive), tc.Config.KeyTTL)
require.Equal(t, "[email protected]", tc.Config.HostLogin)
require.Equal(t, client.ForwardedPorts{
{
SrcIP: "127.0.0.1",
SrcPort: 80,
DestHost: "remote",
DestPort: 180,
},
}, tc.Config.LocalForwardPorts)
require.Equal(t, client.DynamicForwardedPorts{
{
SrcIP: "127.0.0.1",
SrcPort: 8080,
},
}, tc.Config.DynamicForwardedPorts)
require.Equal(t,
map[string]string{"A": "B", "C": "D"},
tc.ExtraProxyHeaders)
_, proxy := makeTestServers(t)
proxyWebAddr, err := proxy.ProxyWebAddr()
require.NoError(t, err)
proxySSHAddr, err := proxy.ProxySSHAddr()
require.NoError(t, err)
// If profile is missing, makeClient should call Ping on the proxy to fetch SSHProxyAddr
conf = CLIConf{
Proxy: proxyWebAddr.String(),
Context: context.Background(),
InsecureSkipVerify: true,
}
tc, err = makeClient(&conf)
require.NoError(t, err)
require.NotNil(t, tc)
require.Equal(t, proxyWebAddr.String(), tc.Config.WebProxyAddr)
require.Equal(t, proxySSHAddr.Addr, tc.Config.SSHProxyAddr)
require.NotNil(t, tc.LocalAgent().ExtendedAgent)
// With provided identity file.
//
// makeClient should call Ping on the proxy to fetch SSHProxyAddr
conf = CLIConf{
Proxy: proxyWebAddr.String(),
IdentityFileIn: "../../../fixtures/certs/identities/tls.pem",
Context: context.Background(),
InsecureSkipVerify: true,
}
tc, err = makeClient(&conf)
require.NoError(t, err)
require.NotNil(t, tc)
require.Equal(t, proxyWebAddr.String(), tc.Config.WebProxyAddr)
require.Equal(t, proxySSHAddr.Addr, tc.Config.SSHProxyAddr)
require.NotNil(t, tc.LocalAgent().ExtendedAgent)
// Client should have an in-memory agent with keys loaded, in case agent
// forwarding is required for proxy recording mode.
agentKeys, err := tc.LocalAgent().ExtendedAgent.List()
require.NoError(t, err)
require.NotEmpty(t, agentKeys)
require.Equal(t, keys.PrivateKeyPolicyNone, tc.PrivateKeyPolicy,
"private key policy should be configured from the identity file temp profile")
}
// accessApprover allows watching and updating access requests
type accessApprover interface {
types.Events
SetAccessRequestState(ctx context.Context, params types.AccessRequestUpdate) error
}
// approveAllAccessRequests starts a loop which watches for pending AccessRequests
// and automatically approves them.
func approveAllAccessRequests(ctx context.Context, approver accessApprover) error {
watcher, err := approver.NewWatcher(ctx, types.Watch{
Name: types.KindAccessRequest,
Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
})
if err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-watcher.Done():
return watcher.Error()
case evt := <-watcher.Events():
if evt.Type != types.OpPut {
continue
}
request, ok := evt.Resource.(types.AccessRequest)
if !ok {
return trace.BadParameter("unexpected event type received: %q", evt.Resource.GetKind())
}
if request.GetState() == types.RequestState_APPROVED {
continue
}
if err := approver.SetAccessRequestState(ctx, types.AccessRequestUpdate{
RequestID: request.GetName(),
State: types.RequestState_APPROVED,
}); err != nil {
return trace.Wrap(err)
}
}
}
}
// TestSSHOnMultipleNodes validates that mfa is enforced when creating SSH
// sessions when set either via role or cluster auth preference.
// Sessions created via hostname and by matched labels are
// verified.
func TestSSHOnMultipleNodes(t *testing.T) {
t.Parallel()
ctx := t.Context()
origin := func(cluster string) string {
return fmt.Sprintf("https://%s", cluster)
}
connector := mockConnector(t)
user, err := user.Current()
require.NoError(t, err)
noAccessRole, err := types.NewRole("no-access", types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{user.Username},
},
Deny: types.RoleConditions{
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
require.NoError(t, err)
sshLoginRole, err := types.NewRole("ssh-login", types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{user.Username},
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
require.NoError(t, err)
perSessionMFARole, err := types.NewRole("mfa-login", types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{user.Username},
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
Options: types.RoleOptions{RequireMFAType: types.RequireMFAType_SESSION},
})
require.NoError(t, err)
alice, err := types.NewUser("alice")
require.NoError(t, err)
alice.SetRoles([]string{"access", "ssh-login"})
const password = "supersecretpassword"
bob, err := types.NewUser("bob")
require.NoError(t, err)
bob.SetRoles([]string{"access", "ssh-login"})
device, err := mocku2f.Create()
require.NoError(t, err)
device.SetPasswordless()
rootAuth, rootProxy := makeTestServers(t,
withBootstrap(connector, alice, bob, noAccessRole, sshLoginRole, perSessionMFARole),
withConfig(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
}),
)
rootAuthAddr, err := rootAuth.AuthAddr()
require.NoError(t, err)
rootProxyAddr, err := rootProxy.ProxyWebAddr()
require.NoError(t, err)
rootTunnelAddr, err := rootProxy.ProxyTunnelAddr()
require.NoError(t, err)
trustedCluster, err := types.NewTrustedCluster("localhost", types.TrustedClusterSpecV2{
Enabled: true,
Roles: []string{},
Token: staticToken,
ProxyAddress: rootProxyAddr.String(),
ReverseTunnelAddress: rootTunnelAddr.String(),
RoleMap: []types.RoleMapping{
{
Remote: "access",
Local: []string{"access"},
},
{
Remote: perSessionMFARole.GetName(),
Local: []string{perSessionMFARole.GetName()},
},
{
Remote: sshLoginRole.GetName(),
Local: []string{sshLoginRole.GetName()},
},
},
})
require.NoError(t, err)
leafAuth, leafProxy := makeTestServers(t,
withClusterName(t, "leafcluster"),
withBootstrap(connector, alice, sshLoginRole, perSessionMFARole),
withConfig(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
}),
)
tryCreateTrustedCluster(t, leafAuth.GetAuthServer(), trustedCluster)
leafAuthAddr, err := leafAuth.AuthAddr()
require.NoError(t, err)
require.Eventually(t, func() bool {
conns, err := rootAuth.GetAuthServer().GetTunnelConnections("leafcluster")
return err == nil && len(conns) == 1
}, 10*time.Second, 100*time.Millisecond, "leaf cluster never heart beated")
leafProxyAddr := leafProxy.Config.Proxy.WebAddr.String()
stage1Hostname := "test-stage-1"
node := makeTestSSHNode(t, rootAuthAddr, withHostname(stage1Hostname), withSSHLabel("env", "stage"))
sshHostID, err := node.WaitForHostID(ctx)
require.NoError(t, err)
stage2Hostname := "test-stage-2"
node2 := makeTestSSHNode(t, rootAuthAddr, withHostname(stage2Hostname), withSSHLabel("env", "stage"))
sshHostID2, err := node2.WaitForHostID(ctx)
require.NoError(t, err)
prodHostname := "test-prod-1"
nodeProd := makeTestSSHNode(t, rootAuthAddr, withHostname(prodHostname), withSSHLabel("env", "prod"))
sshHostID3, err := nodeProd.WaitForHostID(ctx)
require.NoError(t, err)
leafHostname := "leaf-node"
leafNode := makeTestSSHNode(t, leafAuthAddr, withHostname(leafHostname), withSSHLabel("animal", "llama"))
sshLeafHostID, err := leafNode.WaitForHostID(ctx)
require.NoError(t, err)
hasNodes := func(asrv *auth.Server, hostIDs ...string) func() bool {
return func() bool {
nodes, err := asrv.GetNodes(ctx, apidefaults.Namespace)
if err != nil {
return false
}
foundCount := 0
for _, node := range nodes {
if slices.Contains(hostIDs, node.GetName()) {
foundCount++
}
}
return foundCount == len(hostIDs)
}
}
// wait for auth to see nodes
require.Eventually(t, hasNodes(rootAuth.GetAuthServer(), sshHostID, sshHostID2, sshHostID3),
5*time.Second, 100*time.Millisecond, "nodes never joined root cluster")
require.Eventually(t, hasNodes(leafAuth.GetAuthServer(), sshLeafHostID),
5*time.Second, 100*time.Millisecond, "nodes never joined leaf cluster")
defaultPreference, err := rootAuth.GetAuthServer().GetAuthPreference(ctx)
require.NoError(t, err)
webauthnPreference := func(cluster string) *types.AuthPreferenceV2 {
return &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: cluster,
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
}
}
setupUser := func(cluster, name string, withDevices bool, asrv *auth.Server) {
// set the default auth preference
_, err = asrv.UpsertAuthPreference(ctx, webauthnPreference(cluster))
require.NoError(t, err)
if !withDevices {
return
}
token, err := asrv.CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{
Name: name,
})
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())
ccr, err := device.SignCredentialCreation(origin(cluster), cc)
require.NoError(t, err)
_, err = asrv.ChangeUserAuthentication(ctx, &proto.ChangeUserAuthenticationRequest{
TokenID: tokenID,
NewPassword: []byte(password),
NewMFARegisterResponse: &proto.MFARegisterResponse{
Response: &proto.MFARegisterResponse_Webauthn{
Webauthn: wantypes.CredentialCreationResponseToProto(ccr),
},
},
})
require.NoError(t, err)
}
setupUser("localhost", "alice", true, rootAuth.GetAuthServer())
setupUser("leafcluster", "alice", true, leafAuth.GetAuthServer())
setupUser("localhost", "bob", false, rootAuth.GetAuthServer())
successfulChallenge := func(cluster string) func(ctx context.Context, realOrigin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, _ *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
return func(ctx context.Context, realOrigin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, _ *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
car, err := device.SignAssertion(origin(cluster), assertion) // use the fake origin to prevent a mismatch
if err != nil {
return nil, "", err
}
return &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Webauthn{
Webauthn: wantypes.CredentialAssertionResponseToProto(car),
},
}, "", nil
}
}
failedChallenge := func(cluster string) func(ctx context.Context, realOrigin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, _ *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
return func(ctx context.Context, realOrigin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, _ *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
car, err := device.SignAssertion(origin(cluster), assertion) // use the fake origin to prevent a mismatch
if err != nil {
return nil, "", err
}
carProto := wantypes.CredentialAssertionResponseToProto(car)
carProto.Type = "NOT A VALID TYPE" // set to an invalid type so the ceremony fails
return &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Webauthn{
Webauthn: carProto,
},
}, "", nil
}
}
abortedChallenge := func(ctx context.Context, realOrigin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, _ *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
return nil, "", errors.New("aborted challenge")
}
cases := []struct {
name string
target string
authPreference types.AuthPreference
roles []string
webauthnLogin client.WebauthnLoginFunc
errAssertion require.ErrorAssertionFunc
stdoutAssertion require.ValueAssertionFunc
stderrAssertion require.ValueAssertionFunc
mfaPromptCount int
headless bool
proxyAddr string
auth *auth.Server
cluster string
user types.User
logSuccess []string
}{
{
name: "default auth preference runs commands on multiple nodes without mfa",
authPreference: defaultPreference,
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
target: "env=stage",
stderrAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "[test-stage-1] error\n", i2...)
require.Contains(t, i, "[test-stage-2] error\n", i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "[test-stage-1] test\n", i2...)
require.Contains(t, i, "[test-stage-2] test\n", i2...)
},
errAssertion: require.NoError,
logSuccess: []string{stage1Hostname, stage2Hostname},
},
{
name: "webauthn auth preference runs commands on multiple matches without mfa",
target: "env=stage",
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
stderrAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "[test-stage-1] error\n", i2...)
require.Contains(t, i, "[test-stage-2] error\n", i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "[test-stage-1] test\n", i2...)
require.Contains(t, i, "[test-stage-2] test\n", i2...)
},
errAssertion: require.NoError,
logSuccess: []string{stage1Hostname, stage2Hostname},
},
{
name: "webauthn auth preference runs commands on a single match without mfa",
target: "env=prod",
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
stderrAssertion: func(tt require.TestingT, i any, i2 ...any) {
require.Equal(t, "error\n", i, i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Equal(t, "test\n", i, i2...)
},
errAssertion: require.NoError,
},
{
name: "no matching hosts",
target: "env=dev",
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
errAssertion: require.Error,
stderrAssertion: require.Empty,
stdoutAssertion: require.Empty,
},
{
name: "command runs on multiple matches with mfa set via auth preference",
authPreference: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "localhost",
},
RequireMFAType: types.RequireMFAType_SESSION,
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
webauthnLogin: successfulChallenge("localhost"),
target: "env=stage",
stderrAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "[test-stage-1] error\n", i2...)
require.Contains(t, i, "[test-stage-2] error\n", i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "[test-stage-1] test\n", i2...)
require.Contains(t, i, "[test-stage-2] test\n", i2...)
},
mfaPromptCount: 2,
errAssertion: require.NoError,
logSuccess: []string{stage1Hostname, stage2Hostname},
},
{
name: "command runs on a single match with mfa set via auth preference",
authPreference: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "localhost",
},
RequireMFAType: types.RequireMFAType_SESSION,
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
webauthnLogin: successfulChallenge("localhost"),
target: "env=prod",
stderrAssertion: func(tt require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "error\n", i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Equal(t, "test\n", i, i2...)
},
mfaPromptCount: 1,
errAssertion: require.NoError,
},
{
name: "no matching hosts with mfa",
authPreference: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "localhost",
},
RequireMFAType: types.RequireMFAType_SESSION,
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
webauthnLogin: successfulChallenge("localhost"),
target: "env=dev",
errAssertion: require.Error,
stderrAssertion: require.Empty,
stdoutAssertion: require.Empty,
},
{
name: "command runs on a multiple matches with mfa set via role",
authPreference: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "localhost",
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
roles: []string{"access", sshLoginRole.GetName(), perSessionMFARole.GetName()},
webauthnLogin: successfulChallenge("localhost"),
target: "env=stage",
stderrAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "[test-stage-1] error\n", i2...)
require.Contains(t, i, "[test-stage-2] error\n", i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "[test-stage-1] test\n", i2...)
require.Contains(t, i, "[test-stage-2] test\n", i2...)
},
mfaPromptCount: 2,
errAssertion: require.NoError,
logSuccess: []string{stage1Hostname, stage2Hostname},
},
{
name: "role permits access without mfa",
target: sshHostID,
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
roles: []string{sshLoginRole.GetName()},
stderrAssertion: func(tt require.TestingT, i any, i2 ...any) {
require.Equal(t, "error\n", i, i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Equal(t, "test\n", i, i2...)
},
errAssertion: require.NoError,
},
{
name: "role prevents access",
target: sshHostID,
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
roles: []string{noAccessRole.GetName()},
stdoutAssertion: require.Empty,
stderrAssertion: func(t require.TestingT, v any, i ...any) {
out, ok := v.(string)
require.True(t, ok, i...)
require.Contains(t, out, fmt.Sprintf("access denied to %s connecting to", user.Username), i...)
},
errAssertion: require.Error,
},
{
name: "command runs on a hostname with mfa set via role",
target: sshHostID,
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
roles: []string{perSessionMFARole.GetName()},
webauthnLogin: successfulChallenge("localhost"),
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Equal(t, "test\n", i, i2...)
},
stderrAssertion: func(tt require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "error\n", i2...)
},
mfaPromptCount: 1,
errAssertion: require.NoError,
},
{
name: "failed ceremony when role requires per session mfa",
authPreference: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "localhost",
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
target: sshHostID,
roles: []string{perSessionMFARole.GetName()},
webauthnLogin: failedChallenge("localhost"),
stdoutAssertion: require.Empty,
stderrAssertion: func(t require.TestingT, v any, i ...any) {
out, ok := v.(string)
require.True(t, ok, i...)
require.Contains(t, out, "MFA response validation failed", i...)
},
mfaPromptCount: 1,
errAssertion: require.Error,
},
{
name: "aborted ceremony when role requires per session mfa",
authPreference: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "localhost",
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
target: sshHostID,
roles: []string{perSessionMFARole.GetName()},
webauthnLogin: abortedChallenge,
stdoutAssertion: require.Empty,
stderrAssertion: func(t require.TestingT, v any, i ...any) {
out, ok := v.(string)
require.True(t, ok, i...)
require.Contains(t, out, "aborted challenge", i...)
},
errAssertion: require.Error,
},
{
name: "mfa ceremony prevented when using headless auth",
authPreference: &types.AuthPreferenceV2{
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "localhost",
},
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
},
},
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
target: sshHostID,
roles: []string{perSessionMFARole.GetName()},
webauthnLogin: failedChallenge("localhost"),
stderrAssertion: func(tt require.TestingT, i any, i2 ...any) {
require.Equal(t, "error\n", i, i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Equal(t, "test\n", i, i2...)
},
errAssertion: require.NoError,
headless: true,
},
{
name: "command runs on a leaf node with mfa set via role",
target: sshLeafHostID,
proxyAddr: leafProxyAddr,
auth: leafAuth.GetAuthServer(),
roles: []string{perSessionMFARole.GetName()},
webauthnLogin: successfulChallenge("leafcluster"),
stderrAssertion: func(tt require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "error\n", i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Equal(t, "test\n", i, i2...)
},
mfaPromptCount: 1,
errAssertion: require.NoError,
},
{
name: "command runs on a leaf node via root without mfa",
target: sshLeafHostID,
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
cluster: "leafcluster",
roles: []string{sshLoginRole.GetName()},
webauthnLogin: successfulChallenge("localhost"),
stderrAssertion: func(tt require.TestingT, i any, i2 ...any) {
require.Equal(t, "error\n", i, i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Equal(t, "test\n", i, i2...)
},
errAssertion: require.NoError,
},
{
name: "command runs on a leaf node without mfa",
target: sshLeafHostID,
proxyAddr: leafProxyAddr,
auth: leafAuth.GetAuthServer(),
roles: []string{sshLoginRole.GetName()},
stderrAssertion: func(tt require.TestingT, i any, i2 ...any) {
require.Equal(t, "error\n", i, i2...)
},
webauthnLogin: successfulChallenge("leafcluster"),
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Equal(t, "test\n", i, i2...)
},
errAssertion: require.NoError,
},
{
name: "command runs on a leaf node via root with mfa set via role",
target: sshLeafHostID,
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
cluster: "leafcluster",
roles: []string{perSessionMFARole.GetName()},
webauthnLogin: successfulChallenge("localhost"),
stderrAssertion: func(tt require.TestingT, i any, i2 ...any) {
require.Contains(t, i, "error\n", i2...)
},
stdoutAssertion: func(t require.TestingT, i any, i2 ...any) {
require.Equal(t, "test\n", i, i2...)
},
mfaPromptCount: 1,
errAssertion: require.NoError,
},
{
name: "invalid login on leaf node with no devices enrolled in root",
target: "invalid@" + sshLeafHostID,
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
roles: []string{sshLoginRole.GetName()},
cluster: "leafcluster",
user: bob,
stdoutAssertion: require.Empty,
stderrAssertion: func(t require.TestingT, v any, i ...any) {
out, ok := v.(string)
require.True(t, ok, i...)
require.Contains(t, out, "access denied to invalid connecting to", i...)
},
errAssertion: require.Error,
},
{
name: "invalid login on leaf node with devices enrolled in root",
target: "invalid@" + sshLeafHostID,
proxyAddr: rootProxyAddr.String(),
auth: rootAuth.GetAuthServer(),
roles: []string{sshLoginRole.GetName()},
cluster: "leafcluster",
stdoutAssertion: require.Empty,
stderrAssertion: func(t require.TestingT, v any, i ...any) {
out, ok := v.(string)
require.True(t, ok, i...)
require.Contains(t, out, "access denied to invalid connecting to", i...)
},
errAssertion: require.Error,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
tmpHomePath := t.TempDir()
clusterName, err := tt.auth.GetClusterName(ctx)
require.NoError(t, err)
user := alice
if tt.user != nil {
user = tt.user
}
if tt.authPreference != nil {
_, err = tt.auth.UpsertAuthPreference(ctx, tt.authPreference)
require.NoError(t, err)
t.Cleanup(func() {
_, err = tt.auth.UpsertAuthPreference(ctx, webauthnPreference(clusterName.GetClusterName()))
require.NoError(t, err)
})
}
if tt.roles != nil {
roles := user.GetRoles()
t.Cleanup(func() {
user.SetRoles(roles)
_, err = tt.auth.UpsertUser(ctx, user)
require.NoError(t, err)
})
user.SetRoles(tt.roles)
user, err = tt.auth.UpsertUser(ctx, user)
require.NoError(t, err)
}
err = Run(ctx, []string{
"login",
"-d",
"--insecure",
"--proxy", tt.proxyAddr,
"--user", user.GetName(),
tt.cluster,
}, setHomePath(tmpHomePath), setMockSSOLogin(tt.auth, user, connector.GetName()),
func(cf *CLIConf) error {
cf.WebauthnLogin = tt.webauthnLogin
return nil
},
)
require.NoError(t, err)
stdin := &input{buf: bytes.Buffer{}}
stdout := &output{buf: bytes.Buffer{}}
stderr := &output{buf: bytes.Buffer{}}
// Clear counter before each ssh command,
// so we can assert how many times sign was called.
device.SetCounter(0)
// Sleep before attempting to access the nodes to give them time to show
// up on the server, otherwise a `no target host specified` error is returned.
// 400ms was the lowest sleep that consistently fixed the test.
time.Sleep(400 * time.Millisecond)
args := []string{"ssh", "-d", "--insecure"}
if tt.headless {
args = append(args, "--headless", "--proxy", tt.proxyAddr, "--user", user.GetName())
}
var logDir string
if len(tt.logSuccess) > 0 {
logDir = t.TempDir()
args = append(args, "--log-dir", logDir)
}
args = append(args, tt.target, "echo", "test", "&&", "echo", "error", ">&2")
var runOpts []CliOption
runOpts = append(runOpts, setHomePath(tmpHomePath))
// Only add SSO mock for non-headless tests
if !tt.headless {
runOpts = append(runOpts, setMockSSOLogin(tt.auth, user, connector.GetName()))
}
runOpts = append(runOpts, func(conf *CLIConf) error {
conf.overrideStdin = stdin
conf.OverrideStdout = stdout
conf.overrideStderr = stderr
conf.MockHeadlessLogin = mockHeadlessLogin(t, tt.auth, user)
conf.WebauthnLogin = tt.webauthnLogin
return nil
})
err = Run(ctx, args, runOpts...)
tt.errAssertion(t, err)
tt.stdoutAssertion(t, stdout.String())
tt.stderrAssertion(t, stderr.String())
require.Equal(t, tt.mfaPromptCount, int(device.Counter()), "device sign count mismatch")
// Check for logs if enabled.
if len(tt.logSuccess) > 0 {
succeededFile := filepath.Join(logDir, "hosts.succeeded")
assert.FileExists(t, succeededFile)
succeededContents, err := os.ReadFile(succeededFile)
assert.NoError(t, err)
for _, host := range tt.logSuccess {
assert.Contains(t, string(succeededContents), host)
stdoutFile := filepath.Join(logDir, host+".stdout")
assert.FileExists(t, stdoutFile)
contents, err := os.ReadFile(stdoutFile)
assert.NoError(t, err)
assert.Equal(t, "test\n", string(contents))
stderrFile := filepath.Join(logDir, host+".stderr")
assert.FileExists(t, stderrFile)
contents, err = os.ReadFile(stderrFile)
assert.NoError(t, err)
assert.Equal(t, "error\n", string(contents))
}
}
})
}
}
type input struct {
lock sync.Mutex
buf bytes.Buffer
}
func (i *input) Read(p []byte) (int, error) {
i.lock.Lock()
defer i.lock.Unlock()
return i.buf.Read(p)
}
type output struct {
lock sync.Mutex
buf bytes.Buffer
}
func (o *output) Write(p []byte) (int, error) {
o.lock.Lock()
defer o.lock.Unlock()
return o.buf.Write(p)
}
func (o *output) String() string {
o.lock.Lock()
defer o.lock.Unlock()
return o.buf.String()
}
func TestNoRelogin(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
authProcess, proxyProcess := makeTestServers(t,
withBootstrap(connector, alice),
)
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
err = Run(context.Background(), []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
"--user", "alice",
}, setHomePath(tmpHomePath), setMockSSOLogin(authProcess.GetAuthServer(), alice, connector.GetName()))
require.NoError(t, err)
reloginErr := errors.New("relogin failed")
trackingLoginFunc := func(ctx context.Context, connectorID string, keyRing *client.KeyRing, protocol string) (*authclient.SSHLoginResponse, error) {
return nil, reloginErr
}
for _, tc := range []struct {
desc string
extraArgs []string
errorAssertion func(*testing.T, error)
}{
{
// Should try to relogin due to bad parameter without passing --relogin.
desc: "default",
errorAssertion: func(t *testing.T, err error) {
require.ErrorIs(t, err, reloginErr)
},
},
{
// Should try to relogin due to bad parameter when passing --relogin.
desc: "relogin",
extraArgs: []string{"--relogin"},
errorAssertion: func(t *testing.T, err error) {
require.ErrorIs(t, err, reloginErr)
},
},
{
// Should skip relogin and fail instantly when passing --no-relogin.
desc: "no relogin",
extraArgs: []string{"--no-relogin"},
errorAssertion: func(t *testing.T, err error) {
require.Error(t, err)
require.NotErrorIs(t, err, reloginErr)
},
},
} {
t.Run(tc.desc, func(t *testing.T) {
t.Parallel()
tshArgs := []string{"ssh"}
tshArgs = append(tshArgs, tc.extraArgs...)
tshArgs = append(tshArgs,
"--insecure",
"--user", "alice",
"--proxy", proxyAddr.String(),
"12.12.12.12:8080",
"uptime",
)
err := Run(context.Background(), tshArgs, setHomePath(tmpHomePath), setMockSSOLoginCustom(trackingLoginFunc, connector.GetName()))
tc.errorAssertion(t, err)
})
}
}
// TestSSHAccessRequest tests that a user can automatically request access to a
// ssh server using a resource access request when "tsh ssh" fails with
// AccessDenied.
func TestSSHAccessRequest(t *testing.T) {
modulestest.SetTestModules(t, modulestest.Modules{TestBuildType: modules.BuildEnterprise})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
requester, err := types.NewRole("requester", types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"node-access"},
SearchAsRoles: []string{"node-access"},
},
},
})
require.NoError(t, err)
emptyRole, err := types.NewRole("empty", types.RoleSpecV6{})
require.NoError(t, err)
searchOnlyRequester, err := types.NewRole("search-only-requester", types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"empty"},
SearchAsRoles: []string{"node-access"},
},
},
})
require.NoError(t, err)
user, err := user.Current()
require.NoError(t, err)
nodeAccessRole, err := types.NewRole("node-access", types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{
"access": {"true"},
},
Logins: []string{user.Username},
},
})
require.NoError(t, err)
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"requester"})
traits := map[string][]string{
constants.TraitLogins: {user.Username},
}
alice.SetTraits(traits)
rootAuth, rootProxy := makeTestServers(t,
withBootstrap(requester, searchOnlyRequester, nodeAccessRole, emptyRole, connector, alice),
// Do not use a fake clock to better imitate real-world behavior.
)
authAddr, err := rootAuth.AuthAddr()
require.NoError(t, err)
proxyAddr, err := rootProxy.ProxyWebAddr()
require.NoError(t, err)
sshHostname := "test-ssh-server"
node := makeTestSSHNode(t, authAddr, withHostname(sshHostname), withSSHLabel("access", "true"))
sshHostID, err := node.WaitForHostID(ctx)
require.NoError(t, err)
sshHostname2 := "test-ssh-server-2"
node2 := makeTestSSHNode(t, authAddr, withHostname(sshHostname2), withSSHLabel("access", "true"))
sshHostID2, err := node2.WaitForHostID(ctx)
require.NoError(t, err)
sshHostnameNoAccess := "test-ssh-server-no-access"
nodeNoAccess := makeTestSSHNode(t, authAddr, withHostname(sshHostnameNoAccess), withSSHLabel("access", "false"))
sshHostIDNoAccess, err := nodeNoAccess.WaitForHostID(ctx)
require.NoError(t, err)
hasNodes := func(hostIDs ...string) func() bool {
return func() bool {
nodes, err := rootAuth.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
require.NoError(t, err)
foundCount := 0
for _, node := range nodes {
if slices.Contains(hostIDs, node.GetName()) {
foundCount++
}
}
return foundCount == len(hostIDs)
}
}
// wait for auth to see nodes
require.Eventually(t, hasNodes(sshHostID, sshHostID2, sshHostIDNoAccess),
10*time.Second, 100*time.Millisecond, "nodes never showed up")
// approve all requests as they're created
errChan := make(chan error)
t.Cleanup(func() {
require.ErrorIs(t, <-errChan, context.Canceled, "unexpected error from approveAllAccessRequests")
})
go func() {
err := approveAllAccessRequests(ctx, rootAuth.GetAuthServer())
// Cancel the context, so Run calls don't block
cancel()
errChan <- err
}()
tmpHomePath := t.TempDir()
err = Run(ctx, []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
"--user", "alice",
}, setHomePath(tmpHomePath), setMockSSOLogin(rootAuth.GetAuthServer(), alice, connector.GetName()))
require.NoError(t, err)
// won't request if can't list node
err = Run(ctx, []string{
"ssh",
"--insecure",
"--request-reason", "reason here to bypass prompt",
fmt.Sprintf("%s@%s", user.Username, sshHostnameNoAccess),
"echo", "test",
}, setHomePath(tmpHomePath))
require.Error(t, err)
// won't request if can't login with username
err = Run(ctx, []string{
"ssh",
"--insecure",
"--request-reason", "reason here to bypass prompt",
fmt.Sprintf("%s@%s", "not-a-username", sshHostname),
"echo", "test",
}, setHomePath(tmpHomePath))
require.Error(t, err)
// won't request to non-existent node
err = Run(ctx, []string{
"ssh",
"--insecure",
fmt.Sprintf("%s@unknown", user.Username),
"echo", "test",
}, setHomePath(tmpHomePath))
require.Error(t, err)
// won't request if explicitly disabled
err = Run(ctx, []string{
"ssh",
"--insecure",
"--request-reason", "reason here to bypass prompt",
"--request-mode", accessRequestModeOff,
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo", "test",
}, setHomePath(tmpHomePath))
require.Error(t, err)
err = Run(ctx, []string{
"ssh",
"--insecure",
"--request-reason", "reason here to bypass prompt",
"--disable-access-request",
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo", "test",
}, setHomePath(tmpHomePath))
require.Error(t, err)
tests := []struct {
name string
requestMode string
assertNonApprovedNodeAccess require.ErrorAssertionFunc
assertSearchRolesOnly require.ErrorAssertionFunc
}{
{
name: "resource-based",
requestMode: accessRequestModeResource,
assertNonApprovedNodeAccess: require.Error,
assertSearchRolesOnly: require.NoError,
},
{
name: "role-based",
requestMode: accessRequestModeRole,
assertNonApprovedNodeAccess: require.NoError,
assertSearchRolesOnly: require.Error,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
alice.SetRoles([]string{"requester"})
_, err = rootAuth.GetAuthServer().UpsertUser(ctx, alice)
require.NoError(t, err)
err = Run(ctx, []string{
"logout",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
err = Run(ctx, []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
"--user", "alice",
}, setHomePath(tmpHomePath), setMockSSOLogin(rootAuth.GetAuthServer(), alice, connector.GetName()))
require.NoError(t, err)
requestReason := uuid.New().String()
// the first ssh request can fail if the proxy node watcher doesn't know
// about the nodes yet, retry a few times until it works
require.Eventually(t, func() bool {
// ssh with request, by hostname
err := Run(ctx, []string{
"ssh",
"--debug",
"--insecure",
"--request-mode", tc.requestMode,
"--request-reason", requestReason,
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo", "test",
}, setHomePath(tmpHomePath))
if err != nil {
t.Logf("Got error while trying to SSH to node, retrying. Error: %v", err)
}
return err == nil
}, 10*time.Second, 100*time.Millisecond, "failed to ssh with retries")
requests, err := rootAuth.GetAuthServer().GetAccessRequests(ctx, types.AccessRequestFilter{})
require.NoError(t, err)
require.True(t, slices.ContainsFunc(requests, func(request types.AccessRequest) bool {
return request.GetRequestReason() == requestReason
}), "access request with the specified reason was not found")
// now that we have an approved access request, it should work without
// prompting for a request reason
err = Run(ctx, []string{
"ssh",
"--insecure",
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo", "test",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
// log out and back in with no access request
err = Run(ctx, []string{
"logout",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
err = Run(ctx, []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
"--user", "alice",
}, setHomePath(tmpHomePath), setMockSSOLogin(rootAuth.GetAuthServer(), alice, connector.GetName()))
require.NoError(t, err)
// ssh with request, by host ID
err = Run(ctx, []string{
"ssh",
"--insecure",
"--request-mode", tc.requestMode,
"--request-reason", "reason here to bypass prompt",
fmt.Sprintf("%s@%s", user.Username, sshHostID),
"echo", "test",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
// check access to non-requested node
err = Run(ctx, []string{
"ssh",
"--insecure",
fmt.Sprintf("%s@%s", user.Username, sshHostname2),
"echo", "test",
}, setHomePath(tmpHomePath))
tc.assertNonApprovedNodeAccess(t, err)
// drop the current access request
err = Run(ctx, []string{
"--insecure",
"request",
"drop",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
// fail to ssh to other node with no active request
err = Run(ctx, []string{
"ssh",
"--insecure",
"--request-mode", accessRequestModeOff,
fmt.Sprintf("%s@%s", user.Username, sshHostname2),
"echo", "test",
}, setHomePath(tmpHomePath))
require.Error(t, err)
// successfully ssh to other node, with new request
err = Run(ctx, []string{
"ssh",
"--insecure",
"--request-mode", tc.requestMode,
"--request-reason", "reason here to bypass prompt",
fmt.Sprintf("%s@%s", user.Username, sshHostname2),
"echo", "test",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
// Check access to nodes when only search_as_roles are available
alice.SetRoles([]string{"search-only-requester"})
_, err = rootAuth.GetAuthServer().UpsertUser(ctx, alice)
require.NoError(t, err)
err = Run(ctx, []string{
"--insecure",
"request",
"drop",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
err = Run(ctx, []string{
"ssh",
"--insecure",
"--request-mode", tc.requestMode,
"--request-reason", "reason here to bypass prompt",
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo", "test",
}, setHomePath(tmpHomePath))
tc.assertSearchRolesOnly(t, err)
})
}
}
func TestAccessRequestOnLeaf(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
requester, err := types.NewRole("requester", types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"access"},
},
},
})
require.NoError(t, err)
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"requester"})
rootAuth, rootProxy := makeTestServers(t,
withBootstrap(requester, connector, alice),
withConfig(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
}),
)
rootAuthServer := rootAuth.GetAuthServer()
require.NotNil(t, rootAuthServer)
rootProxyAddr, err := rootProxy.ProxyWebAddr()
require.NoError(t, err)
rootTunnelAddr, err := rootProxy.ProxyTunnelAddr()
require.NoError(t, err)
trustedCluster, err := types.NewTrustedCluster("localhost", types.TrustedClusterSpecV2{
Enabled: true,
Roles: []string{},
Token: staticToken,
ProxyAddress: rootProxyAddr.String(),
ReverseTunnelAddress: rootTunnelAddr.String(),
RoleMap: []types.RoleMapping{
{
Remote: "access",
Local: []string{"access"},
},
},
})
require.NoError(t, err)
leafAuth, _ := makeTestServers(t,
withClusterName(t, "leafcluster"),
withConfig(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
}),
)
tryCreateTrustedCluster(t, leafAuth.GetAuthServer(), trustedCluster)
err = Run(ctx, []string{
"login",
"--insecure",
"--debug",
"--proxy", rootProxyAddr.String(),
}, setHomePath(tmpHomePath), setMockSSOLogin(rootAuthServer, alice, connector.GetName()))
require.NoError(t, err)
err = Run(ctx, []string{
"login",
"--insecure",
"--debug",
"--proxy", rootProxyAddr.String(),
"leafcluster",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
err = Run(ctx, []string{
"login",
"--insecure",
"--debug",
"--proxy", rootProxyAddr.String(),
"localhost",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
err = Run(ctx, []string{
"login",
"--insecure",
"--debug",
"--proxy", rootProxyAddr.String(),
"leafcluster",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
// approve all requests as they're created
errChan := make(chan error)
t.Cleanup(func() {
require.ErrorIs(t, <-errChan, context.Canceled, "unexpected error from approveAllAccessRequests")
})
go func() {
err := approveAllAccessRequests(ctx, rootAuth.GetAuthServer())
// Cancel the context, so Run calls don't block
cancel()
errChan <- err
}()
err = Run(ctx, []string{
"request",
"new",
"--insecure",
"--debug",
"--proxy", rootProxyAddr.String(),
"--roles=access",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
}
// TestSSHAccessRequestWait tests that "tsh ssh" automatically creates an
// access request when required and properly waits for it to be approved.
func TestSSHAccessRequestWait(t *testing.T) {
// Access requests require enterprise.
modulestest.SetTestModules(t, modulestest.Modules{TestBuildType: modules.BuildEnterprise})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Set up a requester role the user will have.
requester, err := types.NewRole("requester", types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Roles: []string{"node-access"},
SearchAsRoles: []string{"node-access"},
},
},
})
require.NoError(t, err)
// Set up an access role the user will automatically request.
user, err := user.Current()
require.NoError(t, err)
nodeAccessRole, err := types.NewRole("node-access", types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{
"access": {"true"},
},
Logins: []string{user.Username},
},
})
require.NoError(t, err)
// Use a mock auth connector so the user can log in for the test.
connector := mockConnector(t)
// Create the test user with the requester role.
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"requester"})
// Create the cluster with our user and roles.
rootAuth, rootProxy := makeTestServers(t,
withBootstrap(requester, nodeAccessRole, connector, alice),
)
authAddr, err := rootAuth.AuthAddr()
require.NoError(t, err)
proxyAddr, err := rootProxy.ProxyWebAddr()
require.NoError(t, err)
// Make the SSH node our test user will access.
sshHostname := "test-ssh-server"
node := makeTestSSHNode(t, authAddr, withHostname(sshHostname), withSSHLabel("access", "true"))
sshHostID, err := node.WaitForHostID(ctx)
require.NoError(t, err)
tmpHomePath := t.TempDir()
// Log the user in.
err = Run(ctx, []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
"--user", "alice",
}, setHomePath(tmpHomePath), setMockSSOLogin(rootAuth.GetAuthServer(), alice, connector.GetName()))
require.NoError(t, err)
// Wait for the proxy to see the node so that future SSH attempts may
// succeed, the most reliable way seems to make an SSH attempt and look for
// an AccessDenied error.
require.EventuallyWithT(t, func(t *assert.CollectT) {
var output strings.Builder
err = Run(ctx, []string{
"ssh", "--disable-access-request",
fmt.Sprintf("%s@%s", user.Username, sshHostID),
}, setHomePath(tmpHomePath), func(cf *CLIConf) error {
cf.overrideStderr = &output
return nil
})
require.Error(t, err)
require.Contains(t, output.String(), "access denied")
}, 10*time.Second, 100*time.Millisecond, "node never showed up")
// Initialize an access request watcher.
requestWatcher, err := rootAuth.GetAuthServer().NewWatcher(ctx, types.Watch{
Name: types.KindAccessRequest,
Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
})
require.NoError(t, err)
evt := <-requestWatcher.Events()
require.Equal(t, types.OpInit, evt.Type)
// In the background, start the SSH attempt. It should automatically create
// an access request, wait for it to be approved, and then run the echo
// command on the server.
sshErr := make(chan error, 1)
go func() {
sshErr <- Run(ctx, []string{
"ssh",
"--request-reason", "reason here to bypass prompt",
fmt.Sprintf("%s@%s", user.Username, sshHostID),
"echo", "test",
}, setHomePath(tmpHomePath))
}()
// Wait to see the new access request created.
evt = <-requestWatcher.Events()
require.Equal(t, types.OpPut, evt.Type)
originalRequestID := evt.Resource.GetName()
// Create, then delete, an extraneous access request, to validate that tsh
// does not stop waiting for request approval when a different access
// request gets deleted.
otherRequestID := uuid.NewString()
req, err := types.NewAccessRequest(otherRequestID, "alice", "node-access")
require.NoError(t, err)
err = rootAuth.GetAuthServer().CreateAccessRequest(ctx, req)
require.NoError(t, err)
err = rootAuth.GetAuthServer().DeleteAccessRequest(ctx, otherRequestID)
require.NoError(t, err)
// Approve the original access request.
err = rootAuth.GetAuthServer().SetAccessRequestState(ctx, types.AccessRequestUpdate{
RequestID: originalRequestID,
State: types.RequestState_APPROVED,
})
require.NoError(t, err)
// Wait for the SSH attempt to succeed.
select {
case <-time.After(10 * time.Second):
t.Error("SSH attempt with automatic access request timed out")
case err := <-sshErr:
assert.NoError(t, err, "SSH attempt with automatic access request failed")
}
}
// TestSSHCommand tests that a user can access a single SSH node and run commands.
func TestSSHCommands(t *testing.T) {
modulestest.SetTestModules(t, modulestest.Modules{TestBuildType: modules.BuildEnterprise})
ctx := t.Context()
accessRoleName := "access"
sshHostname := "test-ssh-server"
accessUser, err := types.NewUser(accessRoleName)
require.NoError(t, err)
accessUser.SetRoles([]string{accessRoleName})
user, err := user.Current()
require.NoError(t, err)
accessUser.SetLogins([]string{user.Username})
traits := map[string][]string{
constants.TraitLogins: {user.Username},
}
accessUser.SetTraits(traits)
connector := mockConnector(t)
rootServerOpts := []testserver.TestServerOptFunc{
testserver.WithBootstrap(connector, accessUser),
testserver.WithHostname(sshHostname),
testserver.WithClusterName("root"),
testserver.WithSSHLabel(accessRoleName, "true"),
testserver.WithSSHPublicAddrs("127.0.0.1:0"),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.SSH.Enabled = true
cfg.SSH.PublicAddrs = []utils.NetAddr{cfg.SSH.Addr}
cfg.SSH.DisableCreateHostUser = 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())
})
rootProxyAddr, err := rootServer.ProxyWebAddr()
require.NoError(t, err)
require.EventuallyWithT(t, func(t *assert.CollectT) {
rootNodes, err := rootServer.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, rootNodes, 1)
}, 10*time.Second, 100*time.Millisecond)
tmpHomePath := t.TempDir()
rootAuth := rootServer.GetAuthServer()
err = Run(ctx, []string{
"login",
"--insecure",
"--proxy", rootProxyAddr.String(),
"--user", user.Username,
}, setHomePath(tmpHomePath), setMockSSOLogin(rootAuth, accessUser, connector.GetName()))
require.NoError(t, err)
tests := []struct {
name string
args []string
expected string
shouldErr bool
}{
{
// Test that a simple echo works.
name: "ssh simple command",
expected: "this is a test message",
args: []string{
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo",
"this is a test message",
},
shouldErr: false,
},
{
// Test that commands can be prefixed with a double dash.
name: "ssh command with double dash",
expected: "this is a test message",
args: []string{
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"--",
"echo",
"this is a test message",
},
shouldErr: false,
},
{
// Test that a double dash is not removed from the middle of a command.
name: "ssh command with double dash in the middle",
expected: "-- this is a test message",
args: []string{
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo",
"--",
"this is a test message",
},
shouldErr: false,
},
{
// Test that quoted commands work (e.g. `tsh ssh 'echo test'`)
name: "ssh command literal",
expected: "this is a test message",
args: []string{
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo this is a test message",
},
shouldErr: false,
},
{
// Test that a double dash is passed as-is in a quoted command (which should fail).
name: "ssh command literal with double dash err",
expected: "",
args: []string{
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"-- echo this is a test message",
},
shouldErr: true,
},
{
// Test that a double dash is not removed from the middle of a quoted command.
name: "ssh command literal with double dash in the middle",
expected: "-- this is a test message",
args: []string{
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo", "-- this is a test message",
},
shouldErr: false,
},
{
// Test tsh ssh -- hostname command
name: "delimiter before host and command",
expected: "this is a test message",
args: []string{
"--", sshHostname, "echo", "this is a test message",
},
shouldErr: false,
},
}
for _, test := range tests {
ctx := context.Background()
t.Run(test.name, func(t *testing.T) {
t.Parallel()
stdout := &output{buf: bytes.Buffer{}}
stderr := &output{buf: bytes.Buffer{}}
args := append(
[]string{
"ssh",
"--insecure",
"--proxy", rootProxyAddr.String(),
},
test.args...,
)
err := Run(ctx, args, setHomePath(tmpHomePath),
func(conf *CLIConf) error {
conf.overrideStdin = &bytes.Buffer{}
conf.OverrideStdout = stdout
conf.overrideStderr = stderr
return nil
},
)
if test.shouldErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, test.expected, strings.TrimSpace(stdout.String()))
require.Empty(t, stderr.String())
}
})
}
}
// tryCreateTrustedCluster performs several attempts to create a trusted cluster,
// retries on connection problems and access denied errors to let caches
// propagate and services to start
//
// Duplicated in integration/integration_test.go
func tryCreateTrustedCluster(t *testing.T, authServer *auth.Server, trustedCluster types.TrustedCluster) {
ctx := t.Context()
for range 10 {
_, err := authServer.UpsertTrustedClusterV2(ctx, trustedCluster)
if err == nil {
return
}
if trace.IsConnectionProblem(err) {
time.Sleep(500 * time.Millisecond)
continue
}
if trace.IsAccessDenied(err) {
time.Sleep(500 * time.Millisecond)
continue
}
require.FailNow(t, "Terminating on unexpected problem", "%v.", err)
}
require.FailNow(t, "Timeout creating trusted cluster")
}
func TestKubeCredentialsLock(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
const kubeClusterName = "kube-cluster"
t.Run("failed client creation doesn't create lockfile", func(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
firstErr := Run(ctx, []string{
"kube",
"credentials",
"--proxy", "fake-proxy",
"--teleport-cluster", "teleport",
"--kube-cluster", kubeClusterName,
}, setHomePath(tmpHomePath))
require.Error(t, firstErr) // Fails because fake proxy doesn't exist
require.NoFileExists(t, keypaths.KubeCredLockfilePath(tmpHomePath, "fake-proxy"))
})
t.Run("kube credentials called multiple times, SSO login called only once", func(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
kubeRole, err := types.NewRole("kube-access", types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesLabels: types.Labels{types.Wildcard: apiutils.Strings{types.Wildcard}},
KubeGroups: []string{kube.TestImpersonationGroup},
KubeUsers: []string{alice.GetName()},
KubernetesResources: []types.KubernetesResource{
{
Kind: "pods", Name: types.Wildcard, Namespace: types.Wildcard, APIGroup: types.Wildcard,
},
},
},
})
require.NoError(t, err)
alice.SetRoles([]string{"access", kubeRole.GetName()})
require.NoError(t, err)
authProcess, proxyProcess := makeTestServers(t, withBootstrap(connector, alice, kubeRole))
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
teleportClusterName, err := authServer.GetClusterName(ctx)
require.NoError(t, err)
kubeCluster, err := types.NewKubernetesClusterV3(types.Metadata{
Name: kubeClusterName,
Labels: map[string]string{},
},
types.KubernetesClusterSpecV3{},
)
require.NoError(t, err)
kubeServer, err := types.NewKubernetesServerV3FromCluster(kubeCluster, kubeClusterName, kubeClusterName)
require.NoError(t, err)
_, err = authServer.UpsertKubernetesServer(context.Background(), kubeServer)
require.NoError(t, err)
require.EventuallyWithT(t, func(t *assert.CollectT) {
gotNames := map[string]struct{}{}
for ks, err := range authServer.UnifiedResourceCache.KubernetesServers(ctx, services.UnifiedResourcesIterateParams{}) {
require.NoError(t, err)
gotNames[ks.GetCluster().GetName()] = struct{}{}
}
require.Contains(t, gotNames, kubeCluster.GetName(), "missing kube cluster")
}, 15*time.Second, 100*time.Millisecond)
var ssoCalls atomic.Int32
mockSSOLogin := mockSSOLogin(authServer, alice)
mockSSOLoginWithCountCalls := func(ctx context.Context, connectorID string, keyRing *client.KeyRing, protocol string) (*authclient.SSHLoginResponse, error) {
ssoCalls.Add(1)
return mockSSOLogin(ctx, connectorID, keyRing, protocol)
}
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr.String(),
}, setHomePath(tmpHomePath), setMockSSOLoginCustom(mockSSOLoginWithCountCalls, connector.GetName()))
require.NoError(t, err)
_, err = profile.FromDir(tmpHomePath, "")
require.NoError(t, err)
ssoCalls.Store(0) // Reset number of calls after setup login
// Overwrite profile data to simulate expired user certificate
expiredSSHCert := `
[email protected] AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgefu/ZQ70TbBMZfGUFHluE7PCu6PiWN0SsA5xrKbkzCkAAAADAQABAAABAQCyGzVvW7vgsK1P2Rtg55DTjL4We0WjSYYdzXJnVbyTxqrEYDOkhSnw4tZTS9KgALb698g0vrqy5bSJXB90d8uLdTmCmPngPbYpSN+p3P2SbIdkB5cRIMspB22qSkfHUARQlYM4PrMYIznWwQRFBvrRNOVdTdbMywlQGMUb0jdxK7JFBx1LC76qfHJhrD7jZS+MtygFIqhAJS9CQXW314p3FmL9s1cPV5lQfY527np8580qMKPkdeowPd/hVGcPA/C+ZxLcN9LqnuTZEFoDvYtwjfofOGUpANwtENBNZbNTxHDk7shYCRN9aZJ50zdFq3rMNdzFlEyJwm2ca+7aRDLlAAAAAAAAAAAAAAABAAAAEWFsaWNlQGV4YW1wbGUuY29tAAAAVQAAADYtdGVsZXBvcnQtbm9sb2dpbi1hZDVhYTViMi00MDBlLTQ2ZmUtOThjOS02ZjRhNDA2YzdlZGMAAAAXLXRlbGVwb3J0LWludGVybmFsLWpvaW4AAAAAZG9PKAAAAABkb0+gAAAAAAAAAQkAAAAXcGVybWl0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAAAAAAEnByaXZhdGUta2V5LXBvbGljeQAAAAgAAAAEbm9uZQAAAA50ZWxlcG9ydC1yb2xlcwAAADUAAAAxeyJ2ZXJzaW9uIjoidjEiLCJyb2xlcyI6WyJhY2Nlc3MiLCJrdWJlLWFjY2VzcyJdfQAAABl0ZWxlcG9ydC1yb3V0ZS10by1jbHVzdGVyAAAADQAAAAlsb2NhbGhvc3QAAAAPdGVsZXBvcnQtdHJhaXRzAAAACAAAAARudWxsAAAAAAAAARcAAAAHc3NoLXJzYQAAAAMBAAEAAAEBAK/vBVOnf+QLSF0aKsEpQuof1o/5EJJ25C07tljSWvF2wNixHOyHZj8kAwO3f2XmWQd/XBddvZtLETvTbdBum8T37oOLepnDR32TzTV7cR7XVvo0pSqwrg0jWuAxt67b2n2BnWOCULdV9mPM8X9q4wRhqQHFGB3+7dD24x5YmVIBFUFJKYfFYh516giKAcNPFSK6eD381+cNXYx3yDO6i/iyrsuhbYVcTlWSV2Zhc0Gytf83QRmpM6hXW8b8hGCui36ffXSYu/9nWHcK7OeaHZzePT7jHptyqcrYSs52VuzikO74jpw8htU6maUeEcR5TBbeBlB+hmHQfwl8bEUeszMAAAEUAAAADHJzYS1zaGEyLTUxMgAAAQAwb7OpVkJP8pz9M4VIoG0DzXe5W2GN2pH0eWq+n/+YgshzcWPyHsPbckCu9IleHhrp6IK8ZyCt2qLi77o9XJxJUCiJxmsnfJYTs5DtWoCqiIRWKtYvSNpML1PH/badQsS/Stg7VUs48Yftg4eJOo4PYfJqDoHRfGimMwTNQ+aIWAYek3QwydlDdEtJ6X8kkHhNnZb0fzUbUyQLzFDXPu++di+AHNOpMOWDBtNZ1Lm3WWja/t5zIv7j6L67ZTzS5JtV7TlcD+lJ7RcBfWow8OtEW5RyyI8918A2q/zbe3OhGD4D2dZxPUhyGPHLLgy9NJqJoueR8qLPQQyMdDl4JKUq
`
privKey := `
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAshs1b1u74LCtT9kbYOeQ04y+FntFo0mGHc1yZ1W8k8aqxGAz
pIUp8OLWU0vSoAC2+vfINL66suW0iVwfdHfLi3U5gpj54D22KUjfqdz9kmyHZAeX
ESDLKQdtqkpHx1AEUJWDOD6zGCM51sEERQb60TTlXU3WzMsJUBjFG9I3cSuyRQcd
Swu+qnxyYaw+42UvjLcoBSKoQCUvQkF1t9eKdxZi/bNXD1eZUH2Odu56fOfNKjCj
5HXqMD3f4VRnDwPwvmcS3DfS6p7k2RBaA72LcI36HzhlKQDcLRDQTWWzU8Rw5O7I
WAkTfWmSedM3Rat6zDXcxZRMicJtnGvu2kQy5QIDAQABAoIBAFBPIn4PAB1lrRBX
FhhQ8iXhzYi3lwP00Cu6Cr77kueTakbYFhE2Fl5O+lNe2h9ZkyiA996IrgiiuRBC
4NAUgExm1ELGFc3+JZhiCrA+PHx8wWPiZETN462hctqZWdpOg1OOxzdiVkEpCRiD
uhgh+JDC6DV1NsjrOEzMjnxoAqXdS8R8HRSr7ATV/28rXCpzBevtsQFWsHFQ069H
uL9JY4AnBdvnu749ClFYuhux/C0zSAsZIu47WUmmyZdIXY/B32vkhbDfKPpCp7Il
5sE9reNGf22jkqjC4wLpCT0L8wuUnJil0Sj1JUQjtZc4vn7Fc1RWZWRZKC4IlI/+
OUSdXhUCgYEA6krEs7B1W3mO8sCXEUpFZv8KfLaa4BeDncGS++qGrz4qSI8JiuUI
M4uLBec+9FHAAMVd5F/JxhcA5J1BA2e9mITEf582ur/lTU2cSYBIY64IPBMR0ask
Q9UdAdu0r/xQ91cdwKiaSrC3bPgCX/Xe6MzaEWMrmdnse3Kl+E49n0cCgYEAwpvB
gtCk/L6lOsQDgLH3zO27qlYUGSPqhy8iIF+4HMMIIvdIOrSMHspEHhbnypQe9cYO
GRcimlb4U0FbHsOmpkfcNHhvBTegmYEYjuWJR0AN8cgaV2b6XytqYB7Gv2kXhjnF
9dvamhy9+4SngywbqZshUlazVW/RfO4+OqXsKnMCgYBwJJOcUqUJwNhsV0S30O4B
S6gwY5MkGf00oHgDPpFzBfVlP5nYsqHHUk6b58DZXtvhQpcbfcHtoAscYizBPYGh
pEMNtx6SKtHNu41IHTAJDj8AyjvoONul4Db/MbN93O7ARSGHmuwnPgi+DsPMPLqS
gaMLWYWAIbAwsoLApGqYdwKBgAZALHoAK5x2nyYBD7+9d6Ecba+t7h1Umv7Wk7kI
eghqd0NwP+Cq1elTQ9bXk4BdO5VXVDKYHKNqcbVy3vNhA2RJ4JfK2n4HaGAl1l0Y
oE0qkIgYjkgKZbZS1arasjWJsZi9GE+qTR4wGCYQ/7Rl4UmUUwCrCj2PRuJFYLhP
hgNjAoGBAKwqiQpwNzbKOq3+pxau6Y32BqUaTV5ut9FEUz0/qzuNoc2S5bCf4wq+
cc/nvPBnXrP+rsubJXjFDfcIjcZ7x41bRMENvP50xD/J94IpK88TGTVa04VHKExx
iUK/veLmZ6XoouiWLCdU1VJz/1Fcwe/IEamg6ETfofvsqOCgcNYJ
-----END RSA PRIVATE KEY-----
`
pubKey := `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCyGzVvW7vgsK1P2Rtg55DTjL4We0WjSYYdzXJnVbyTxqrEYDOkhSnw4tZTS9KgALb698g0vrqy5bSJXB90d8uLdTmCmPngPbYpSN+p3P2SbIdkB5cRIMspB22qSkfHUARQlYM4PrMYIznWwQRFBvrRNOVdTdbMywlQGMUb0jdxK7JFBx1LC76qfHJhrD7jZS+MtygFIqhAJS9CQXW314p3FmL9s1cPV5lQfY527np8580qMKPkdeowPd/hVGcPA/C+ZxLcN9LqnuTZEFoDvYtwjfofOGUpANwtENBNZbNTxHDk7shYCRN9aZJ50zdFq3rMNdzFlEyJwm2ca+7aRDLl
`
err = os.WriteFile(fmt.Sprintf("%s/%s", tmpHomePath, "keys/127.0.0.1/[email protected]"), []byte(privKey), 0o666)
require.NoError(t, err)
err = os.WriteFile(fmt.Sprintf("%s/%s", tmpHomePath, "keys/127.0.0.1/[email protected]"), []byte(pubKey), 0o666)
require.NoError(t, err)
err = os.WriteFile(fmt.Sprintf("%s/%s", tmpHomePath, "keys/127.0.0.1/[email protected]/localhost-cert.pub"), []byte(expiredSSHCert), 0o666)
require.NoError(t, err)
errChan := make(chan error)
runCreds := func() {
credErr := Run(context.Background(), []string{
"kube",
"credentials",
"--insecure",
"--proxy", proxyAddr.String(),
"--teleport-cluster", teleportClusterName.GetClusterName(),
"--kube-cluster", kubeClusterName,
}, setHomePath(tmpHomePath), setMockSSOLoginCustom(mockSSOLoginWithCountCalls, connector.GetName()))
errChan <- credErr
}
// Run kube credentials calls in parallel, only one should actually call SSO login
runsCount := 3
for range runsCount {
go runCreds()
}
for range runsCount {
select {
case err := <-errChan:
if err != nil {
require.ErrorIs(t, err, errKubeCredLockfileFound)
}
case <-time.After(time.Second * 5):
require.Fail(t, "Running kube credentials timed out")
}
}
require.Equal(t, 1, int(ssoCalls.Load()), "SSO login should have been called exactly once")
})
}
func TestSSHHeadlessCLIFlags(t *testing.T) {
var (
proxy = "proxy.example.com:3080"
username = "alice"
clustername = "root-cluster"
)
for _, tc := range []struct {
name string
cliConf CLIConf
envMap map[string]string
assertErr require.ErrorAssertionFunc
assertConfig func(t require.TestingT, c *client.Config)
}{
{
name: "OK --auth headless",
cliConf: CLIConf{
Proxy: proxy,
AuthConnector: constants.HeadlessConnector,
Username: "username",
},
assertErr: require.NoError,
assertConfig: func(t require.TestingT, c *client.Config) {
require.Equal(t, constants.HeadlessConnector, c.AuthConnector)
},
}, {
name: "OK --headless",
cliConf: CLIConf{
Proxy: proxy,
Headless: true,
Username: "username",
},
assertErr: require.NoError,
assertConfig: func(t require.TestingT, c *client.Config) {
require.Equal(t, constants.HeadlessConnector, c.AuthConnector)
},
}, {
name: "OK use ssh session env with headless cli flag",
cliConf: CLIConf{
Headless: true,
},
envMap: map[string]string{
teleport.SSHSessionWebProxyAddr: proxy,
teleport.SSHTeleportUser: username,
teleport.SSHTeleportClusterName: clustername,
},
assertErr: require.NoError,
assertConfig: func(t require.TestingT, c *client.Config) {
require.Equal(t, constants.HeadlessConnector, c.AuthConnector)
require.Equal(t, proxy, c.WebProxyAddr)
require.Equal(t, username, c.Username)
require.Equal(t, clustername, c.SiteName)
},
}, {
name: "OK use ssh session env with headless auth connector cli flag",
cliConf: CLIConf{
AuthConnector: constants.HeadlessConnector,
},
envMap: map[string]string{
teleport.SSHSessionWebProxyAddr: proxy,
teleport.SSHTeleportUser: username,
teleport.SSHTeleportClusterName: clustername,
},
assertErr: require.NoError,
assertConfig: func(t require.TestingT, c *client.Config) {
require.Equal(t, constants.HeadlessConnector, c.AuthConnector)
require.Equal(t, proxy, c.WebProxyAddr)
require.Equal(t, username, c.Username)
require.Equal(t, clustername, c.SiteName)
},
}, {
name: "OK ignore ssh session env without headless",
cliConf: CLIConf{
Proxy: "other-proxy:3080",
Headless: false,
},
envMap: map[string]string{
teleport.SSHSessionWebProxyAddr: proxy,
teleport.SSHTeleportUser: username,
teleport.SSHTeleportClusterName: clustername,
},
assertErr: require.NoError,
assertConfig: func(t require.TestingT, c *client.Config) {
require.Equal(t, "other-proxy:3080", c.WebProxyAddr)
require.Empty(t, c.Username)
require.Empty(t, c.SiteName)
},
}, {
name: "NOK --headless with mismatched auth connector",
cliConf: CLIConf{
Proxy: proxy,
Headless: true,
AuthConnector: constants.LocalConnector,
Username: "username",
},
assertErr: func(t require.TestingT, err error, msgAndArgs ...any) {
require.True(t, trace.IsBadParameter(err), "expected trace.BadParameter error but got %v", err)
},
}, {
name: "NOK --auth headless without explicit user",
cliConf: CLIConf{
Proxy: proxy,
AuthConnector: constants.HeadlessConnector,
},
assertErr: func(t require.TestingT, err error, msgAndArgs ...any) {
require.True(t, trace.IsBadParameter(err), "expected trace.BadParameter error but got %v", err)
},
}, {
name: "NOK --headless without explicit user",
cliConf: CLIConf{
Proxy: proxy,
Headless: true,
},
assertErr: func(t require.TestingT, err error, msgAndArgs ...any) {
require.True(t, trace.IsBadParameter(err), "expected trace.BadParameter error but got %v", err)
},
},
} {
t.Run(tc.name, func(t *testing.T) {
for k, v := range tc.envMap {
t.Setenv(k, v)
}
tc.cliConf.HomePath = t.TempDir()
c, err := loadClientConfigFromCLIConf(&tc.cliConf, proxy)
tc.assertErr(t, err)
if tc.assertConfig != nil {
tc.assertConfig(t, c)
}
})
}
}
func TestSSHHeadless(t *testing.T) {
modulestest.SetTestModules(t, modulestest.Modules{TestBuildType: modules.BuildEnterprise})
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
user, err := user.Current()
require.NoError(t, err)
// Headless ssh should pass session mfa requirements
nodeAccess, err := types.NewRole("node-access", types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
},
Allow: types.RoleConditions{
Logins: []string{user.Username},
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
require.NoError(t, err)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"node-access"})
requester, err := types.NewRole("requester", types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
SearchAsRoles: []string{"node-access"},
},
},
})
require.NoError(t, err)
bob, err := types.NewUser("[email protected]")
require.NoError(t, err)
bob.SetRoles([]string{"requester"})
sshHostname := "test-ssh-host"
server, err := testserver.NewTeleportProcess(t.TempDir(),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.Hostname = sshHostname
cfg.Auth.Enabled = true
cfg.Proxy.Enabled = true
cfg.SSH.Enabled = true
cfg.SSH.DisableCreateHostUser = true
cfg.Auth.BootstrapResources = []types.Resource{nodeAccess, alice, requester, bob}
cfg.Auth.Preference = &types.AuthPreferenceV2{
Metadata: types.Metadata{
Labels: map[string]string{types.OriginLabel: types.OriginConfigFile},
},
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "127.0.0.1",
},
AllowHeadless: types.NewBoolOption(true),
},
}
}),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, server.Close())
require.NoError(t, server.Wait())
})
require.EventuallyWithT(t, func(t *assert.CollectT) {
found, err := server.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, found, 1)
}, 10*time.Second, 100*time.Millisecond)
go func() {
// Ensure the context is canceled, so that Run calls don't block
defer cancel()
if err := approveAllAccessRequests(ctx, server.GetAuthServer()); err != nil {
assert.ErrorIs(t, err, context.Canceled, "unexpected error from approveAllAccessRequests")
}
}()
proxyAddr, err := server.ProxyWebAddr()
require.NoError(t, err)
for _, tc := range []struct {
name string
args []string
envMap map[string]string
assertErr require.ErrorAssertionFunc
}{
{
name: "node access",
args: []string{"--headless", "--user", "alice", "--proxy", proxyAddr.String()},
assertErr: require.NoError,
}, {
name: "resource request",
args: []string{"--headless", "--user", "bob", "--request-reason", "reason here to bypass prompt", "--proxy", proxyAddr.String()},
assertErr: require.NoError,
}, {
name: "ssh env variables",
args: []string{"--headless"},
envMap: map[string]string{
teleport.SSHSessionWebProxyAddr: proxyAddr.String(),
teleport.SSHTeleportUser: "alice",
},
assertErr: require.NoError,
},
} {
t.Run(tc.name, func(t *testing.T) {
for k, v := range tc.envMap {
t.Setenv(k, v)
}
args := append([]string{
"ssh",
"-d",
"--insecure",
"--proxy", proxyAddr.String(),
}, tc.args...)
args = append(args,
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo", "test",
)
err := Run(ctx, args, func(cf *CLIConf) error {
cf.MockHeadlessLogin = mockHeadlessLogin(t, server.GetAuthServer(), alice)
return nil
})
tc.assertErr(t, err)
})
}
}
func TestHeadlessDoesNotAddKeysToAgent(t *testing.T) {
modulestest.SetTestModules(t, modulestest.Modules{TestBuildType: modules.BuildEnterprise})
agentKeyring, _ := createAgent(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
user, err := user.Current()
require.NoError(t, err)
// Headless ssh should pass session mfa requirements
nodeAccess, err := types.NewRole("node-access", types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
},
Allow: types.RoleConditions{
Logins: []string{user.Username},
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
require.NoError(t, err)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"node-access"})
sshHostname := "test-ssh-host"
server, err := testserver.NewTeleportProcess(t.TempDir(),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.Hostname = sshHostname
cfg.Auth.Enabled = true
cfg.Proxy.Enabled = true
cfg.SSH.Enabled = true
cfg.SSH.DisableCreateHostUser = true
cfg.Auth.BootstrapResources = []types.Resource{nodeAccess, alice}
cfg.Auth.Preference = &types.AuthPreferenceV2{
Metadata: types.Metadata{
Labels: map[string]string{types.OriginLabel: types.OriginConfigFile},
},
Spec: types.AuthPreferenceSpecV2{
Type: constants.Local,
SecondFactor: constants.SecondFactorOptional,
Webauthn: &types.Webauthn{
RPID: "127.0.0.1",
},
AllowHeadless: types.NewBoolOption(true),
},
}
}))
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, server.Close())
require.NoError(t, server.Wait())
})
require.EventuallyWithT(t, func(t *assert.CollectT) {
found, err := server.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, found, 1)
}, 10*time.Second, 100*time.Millisecond)
proxyAddr, err := server.ProxyWebAddr()
require.NoError(t, err)
go func() {
// Ensure the context is canceled, so that Run calls don't block
defer cancel()
if err := approveAllAccessRequests(ctx, server.GetAuthServer()); err != nil {
assert.ErrorIs(t, err, context.Canceled, "unexpected error from approveAllAccessRequests")
}
}()
err = Run(ctx, []string{
"ssh",
"-d",
"--insecure",
"--proxy", proxyAddr.String(),
"--headless",
"--user", "alice",
"--add-keys-to-agent=yes",
fmt.Sprintf("%s@%s", user.Username, sshHostname),
"echo", "test",
}, func(cf *CLIConf) error {
cf.MockHeadlessLogin = mockHeadlessLogin(t, server.GetAuthServer(), alice)
return nil
})
require.NoError(t, err)
keys, err := agentKeyring.List()
require.NoError(t, err)
require.Empty(t, keys)
}
func TestFormatConnectCommand(t *testing.T) {
t.Parallel()
tests := []struct {
clusterFlag string
comment string
db tlsca.RouteToDatabase
command string
}{
{
comment: "no default user/database are specified",
db: tlsca.RouteToDatabase{
ServiceName: "test",
Protocol: defaults.ProtocolPostgres,
},
command: `tsh db connect --db-user=<user> --db-name=<name> test`,
},
{
comment: "default user is specified",
db: tlsca.RouteToDatabase{
ServiceName: "test",
Protocol: defaults.ProtocolPostgres,
Username: "postgres",
},
command: `tsh db connect --db-name=<name> test`,
},
{
comment: "default database is specified",
db: tlsca.RouteToDatabase{
ServiceName: "test",
Protocol: defaults.ProtocolPostgres,
Database: "postgres",
},
command: `tsh db connect --db-user=<user> test`,
},
{
comment: "default user/database are specified",
db: tlsca.RouteToDatabase{
ServiceName: "test",
Protocol: defaults.ProtocolPostgres,
Username: "postgres",
Database: "postgres",
},
command: `tsh db connect test`,
},
{
comment: "extra cluster flag",
clusterFlag: "leaf",
db: tlsca.RouteToDatabase{
ServiceName: "test",
Protocol: defaults.ProtocolPostgres,
Database: "postgres",
},
command: `tsh db connect --cluster=leaf --db-user=<user> test`,
},
}
for _, test := range tests {
t.Run(test.comment, func(t *testing.T) {
require.Equal(t, test.command, formatDatabaseConnectCommand(test.clusterFlag, test.db))
})
}
}
func TestEnvFlags(t *testing.T) {
type testCase struct {
inCLIConf CLIConf
envMap map[string]string
outCLIConf CLIConf
}
testEnvFlag := func(tc testCase) func(t *testing.T) {
return func(t *testing.T) {
for k, v := range tc.envMap {
t.Setenv(k, v)
}
setEnvFlags(&tc.inCLIConf)
require.Equal(t, tc.outCLIConf, tc.inCLIConf)
}
}
t.Run("cluster env", func(t *testing.T) {
t.Run("nothing set", testEnvFlag(testCase{
outCLIConf: CLIConf{
SiteName: "",
},
}))
t.Run("CLI flag is set", testEnvFlag(testCase{
inCLIConf: CLIConf{
SiteName: "a.example.com",
},
outCLIConf: CLIConf{
SiteName: "a.example.com",
},
}))
t.Run("TELEPORT_SITE set", testEnvFlag(testCase{
envMap: map[string]string{
siteEnvVar: "a.example.com",
},
outCLIConf: CLIConf{
SiteName: "a.example.com",
},
}))
t.Run("TELEPORT_CLUSTER set", testEnvFlag(testCase{
envMap: map[string]string{
clusterEnvVar: "b.example.com",
},
outCLIConf: CLIConf{
SiteName: "b.example.com",
},
}))
t.Run("TELEPORT_SITE and TELEPORT_CLUSTER set, prefer TELEPORT_CLUSTER", testEnvFlag(testCase{
envMap: map[string]string{
clusterEnvVar: "d.example.com",
siteEnvVar: "c.example.com",
},
outCLIConf: CLIConf{
SiteName: "d.example.com",
},
}))
t.Run("TELEPORT_SITE and TELEPORT_CLUSTER and CLI flag is set, prefer CLI", testEnvFlag(testCase{
inCLIConf: CLIConf{
SiteName: "e.example.com",
},
envMap: map[string]string{
clusterEnvVar: "g.example.com",
siteEnvVar: "f.example.com",
},
outCLIConf: CLIConf{
SiteName: "e.example.com",
},
}))
})
t.Run("kube cluster env", func(t *testing.T) {
t.Run("nothing set", testEnvFlag(testCase{
outCLIConf: CLIConf{
KubernetesCluster: "",
},
}))
t.Run("CLI flag is set", testEnvFlag(testCase{
inCLIConf: CLIConf{
KubernetesCluster: "a.example.com",
},
outCLIConf: CLIConf{
KubernetesCluster: "a.example.com",
},
}))
t.Run("TELEPORT_KUBE_CLUSTER set", testEnvFlag(testCase{
envMap: map[string]string{
kubeClusterEnvVar: "a.example.com",
},
outCLIConf: CLIConf{
KubernetesCluster: "a.example.com",
},
}))
t.Run("TELEPORT_KUBE_CLUSTER and CLI flag is set, prefer CLI", testEnvFlag(testCase{
inCLIConf: CLIConf{
KubernetesCluster: "e.example.com",
},
envMap: map[string]string{
kubeClusterEnvVar: "g.example.com",
},
outCLIConf: CLIConf{
KubernetesCluster: "e.example.com",
},
}))
})
t.Run("teleport home env", func(t *testing.T) {
t.Run("nothing set", testEnvFlag(testCase{
outCLIConf: CLIConf{},
}))
t.Run("CLI flag is set", testEnvFlag(testCase{
inCLIConf: CLIConf{
HomePath: "teleport-data",
},
outCLIConf: CLIConf{
HomePath: "teleport-data",
},
}))
t.Run("TELEPORT_HOME set", testEnvFlag(testCase{
envMap: map[string]string{
types.HomeEnvVar: "teleport-data/",
},
outCLIConf: CLIConf{
HomePath: "teleport-data",
},
}))
t.Run("TELEPORT_HOME and CLI flag is set, prefer env", testEnvFlag(testCase{
inCLIConf: CLIConf{
HomePath: "teleport-data",
},
envMap: map[string]string{
types.HomeEnvVar: "teleport-data/",
},
outCLIConf: CLIConf{
HomePath: "teleport-data",
},
}))
})
t.Run("tsh global config path", func(t *testing.T) {
t.Run("nothing set", testEnvFlag(testCase{
outCLIConf: CLIConf{},
}))
t.Run("TELEPORT_GLOBAL_TSH_CONFIG set", testEnvFlag(testCase{
envMap: map[string]string{
globalTshConfigEnvVar: "/opt/teleport/tsh.yaml",
},
outCLIConf: CLIConf{
GlobalTshConfigPath: "/opt/teleport/tsh.yaml",
},
}))
})
t.Run("tsh ssh session env", func(t *testing.T) {
t.Run("does not overwrite cli flags", testEnvFlag(testCase{
inCLIConf: CLIConf{
Headless: true,
Proxy: "proxy.example.com",
Username: "alice",
SiteName: "root-cluster",
},
envMap: map[string]string{
teleport.SSHSessionWebProxyAddr: "other.example.com",
teleport.SSHTeleportUser: "bob",
teleport.SSHTeleportClusterName: "leaf-cluster",
},
outCLIConf: CLIConf{
Headless: true,
Proxy: "proxy.example.com",
Username: "alice",
SiteName: "root-cluster",
},
}))
})
}
func TestKubeConfigUpdate(t *testing.T) {
t.Parallel()
// don't need real creds for this test, just something to compare against
creds := &client.KeyRing{KeyRingIndex: client.KeyRingIndex{ProxyHost: "a.example.com"}}
tests := []struct {
desc string
cf *CLIConf
kubeStatus *kubernetesStatus
errorAssertion require.ErrorAssertionFunc
expectedValues *kubeconfig.Values
}{
{
desc: "selected cluster",
cf: &CLIConf{
executablePath: "/bin/tsh",
KubernetesCluster: "dev",
},
kubeStatus: &kubernetesStatus{
clusterAddr: "https://a.example.com:3026",
teleportClusterName: "a.example.com",
kubeClusters: []types.KubeCluster{
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "dev",
},
},
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "prod",
},
},
},
credentials: creds,
},
errorAssertion: require.NoError,
expectedValues: &kubeconfig.Values{
Credentials: creds,
ClusterAddr: "https://a.example.com:3026",
TeleportClusterName: "a.example.com",
KubeClusters: []string{"dev"},
SelectCluster: "dev",
Exec: &kubeconfig.ExecValues{
TshBinaryPath: "/bin/tsh",
Env: make(map[string]string),
},
},
},
{
desc: "selected cluster with impersonation and namespace",
cf: &CLIConf{
executablePath: "/bin/tsh",
KubernetesCluster: "dev",
kubeNamespace: "namespace1",
kubernetesImpersonationConfig: impersonationConfig{
kubernetesUser: "user1",
kubernetesGroups: []string{"group1", "group2"},
},
},
kubeStatus: &kubernetesStatus{
clusterAddr: "https://a.example.com:3026",
teleportClusterName: "a.example.com",
kubeClusters: []types.KubeCluster{
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "dev",
},
},
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "prod",
},
},
},
credentials: creds,
},
errorAssertion: require.NoError,
expectedValues: &kubeconfig.Values{
Credentials: creds,
ClusterAddr: "https://a.example.com:3026",
TeleportClusterName: "a.example.com",
Impersonate: "user1",
ImpersonateGroups: []string{"group1", "group2"},
Namespace: "namespace1",
KubeClusters: []string{"dev"},
SelectCluster: "dev",
Exec: &kubeconfig.ExecValues{
TshBinaryPath: "/bin/tsh",
Env: make(map[string]string),
},
},
},
{
desc: "no selected cluster",
cf: &CLIConf{
executablePath: "/bin/tsh",
KubernetesCluster: "",
},
kubeStatus: &kubernetesStatus{
clusterAddr: "https://a.example.com:3026",
teleportClusterName: "a.example.com",
kubeClusters: []types.KubeCluster{
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "dev",
},
},
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "prod",
},
},
},
credentials: creds,
},
errorAssertion: require.NoError,
expectedValues: &kubeconfig.Values{
Credentials: creds,
ClusterAddr: "https://a.example.com:3026",
TeleportClusterName: "a.example.com",
KubeClusters: []string{"dev", "prod"},
SelectCluster: "",
Exec: &kubeconfig.ExecValues{
TshBinaryPath: "/bin/tsh",
Env: make(map[string]string),
},
},
},
{
desc: "invalid selected cluster",
cf: &CLIConf{
executablePath: "/bin/tsh",
KubernetesCluster: "invalid",
},
kubeStatus: &kubernetesStatus{
clusterAddr: "https://a.example.com:3026",
teleportClusterName: "a.example.com",
kubeClusters: []types.KubeCluster{
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "dev",
},
},
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "prod",
},
},
},
credentials: creds,
},
errorAssertion: func(t require.TestingT, err error, _ ...any) {
require.True(t, trace.IsNotFound(err))
},
expectedValues: nil,
},
{
desc: "no kube clusters",
cf: &CLIConf{
executablePath: "/bin/tsh",
KubernetesCluster: "",
},
kubeStatus: &kubernetesStatus{
clusterAddr: "https://a.example.com:3026",
teleportClusterName: "a.example.com",
kubeClusters: []types.KubeCluster{},
credentials: creds,
},
errorAssertion: require.NoError,
expectedValues: &kubeconfig.Values{
Credentials: creds,
ClusterAddr: "https://a.example.com:3026",
TeleportClusterName: "a.example.com",
Exec: nil,
},
},
{
desc: "no tsh path",
cf: &CLIConf{
executablePath: "",
KubernetesCluster: "dev",
},
kubeStatus: &kubernetesStatus{
clusterAddr: "https://a.example.com:3026",
teleportClusterName: "a.example.com",
kubeClusters: []types.KubeCluster{
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "dev",
},
},
&types.KubernetesClusterV3{
Metadata: types.Metadata{
Name: "prod",
},
},
},
credentials: creds,
},
errorAssertion: require.NoError,
expectedValues: &kubeconfig.Values{
Credentials: creds,
ClusterAddr: "https://a.example.com:3026",
TeleportClusterName: "a.example.com",
Exec: nil,
SelectCluster: "dev",
},
},
}
for _, testcase := range tests {
t.Run(testcase.desc, func(t *testing.T) {
values, err := buildKubeConfigUpdate(testcase.cf, testcase.kubeStatus, "")
testcase.errorAssertion(t, err)
require.Equal(t, testcase.expectedValues, values)
})
}
}
func TestSetX11Config(t *testing.T) {
for _, tc := range []struct {
desc string
cf CLIConf
opts []string
envMap map[string]string
assertError require.ErrorAssertionFunc
expectConfig client.Config
}{
// Test Teleport flag usage
{
desc: "-X",
cf: CLIConf{
X11ForwardingUntrusted: true,
},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: false,
},
},
{
desc: "-Y",
cf: CLIConf{
X11ForwardingTrusted: true,
},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: true,
},
},
{
desc: "--x11-untrustedTimeout=1m",
cf: CLIConf{
X11ForwardingTimeout: time.Minute,
},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
X11ForwardingTimeout: time.Minute,
},
},
{
desc: "$DISPLAY not set",
cf: CLIConf{
X11ForwardingUntrusted: true,
},
envMap: map[string]string{x11.DisplayEnv: ""},
assertError: require.Error,
expectConfig: client.Config{
EnableX11Forwarding: false,
},
},
// Test OpenSSH flag usage
{
desc: "-oForwardX11=yes",
opts: []string{"ForwardX11=yes"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: true,
},
},
{
desc: "-oForwardX11Trusted=yes",
opts: []string{"ForwardX11Trusted=yes"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
X11ForwardingTrusted: true,
},
},
{
desc: "-oForwardX11Trusted=yes",
opts: []string{"ForwardX11Trusted=no"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
X11ForwardingTrusted: false,
},
},
{
desc: "-oForwardX11=yes with -oForwardX11Trusted=yes",
opts: []string{"ForwardX11=yes", "ForwardX11Trusted=yes"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: true,
},
},
{
desc: "-oForwardX11=yes with -oForwardX11Trusted=no",
opts: []string{"ForwardX11=yes", "ForwardX11Trusted=no"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: false,
},
},
{
desc: "-oForwardX11Timeout=60",
opts: []string{"ForwardX11Timeout=60"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
X11ForwardingTimeout: time.Minute,
},
},
// Test Combined usage - options generally take priority
{
desc: "-X with -oForwardX11=yes",
cf: CLIConf{
X11ForwardingUntrusted: true,
},
opts: []string{"ForwardX11=yes"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: true,
},
},
{
desc: "-X with -oForwardX11Trusted=yes",
cf: CLIConf{
X11ForwardingUntrusted: true,
},
opts: []string{"ForwardX11Trusted=yes"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: true,
},
},
{
desc: "-X with -oForwardX11Trusted=no",
cf: CLIConf{
X11ForwardingUntrusted: true,
},
opts: []string{"ForwardX11Trusted=no"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: false,
},
},
{
desc: "-Y with -oForwardX11Trusted=yes",
cf: CLIConf{
X11ForwardingTrusted: true,
},
opts: []string{"ForwardX11Trusted=yes"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: true,
},
},
{
desc: "-Y with -oForwardX11Trusted=no",
cf: CLIConf{
X11ForwardingTrusted: true,
},
opts: []string{"ForwardX11Trusted=no"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
EnableX11Forwarding: true,
X11ForwardingTrusted: true,
},
},
{
desc: "--x11-untrustedTimeout=1m with -oForwardX11Timeout=120",
cf: CLIConf{
X11ForwardingTimeout: time.Minute,
},
opts: []string{"ForwardX11Timeout=120"},
envMap: map[string]string{x11.DisplayEnv: ":0"},
assertError: require.NoError,
expectConfig: client.Config{
X11ForwardingTimeout: time.Minute * 2,
},
},
} {
t.Run(tc.desc, func(t *testing.T) {
for k, v := range tc.envMap {
t.Setenv(k, v)
}
opts, err := parseOptions(tc.opts)
require.NoError(t, err)
clt := client.Config{}
err = setX11Config(&clt, &tc.cf, opts)
tc.assertError(t, err)
require.Equal(t, tc.expectConfig, clt)
})
}
}
// TestAuthClientFromTSHProfile tests if API Client can be successfully created from tsh profile where clusters
// certs are stored separately in CAS directory and in case where legacy certs.pem file was used.
func TestAuthClientFromTSHProfile(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
authProcess, proxyProcess := makeTestServers(t, withBootstrap(connector, alice))
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr.String(),
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()))
require.NoError(t, err)
profile, err := profile.FromDir(tmpHomePath, "")
require.NoError(t, err)
mustCreateAuthClientFormUserProfile(t, tmpHomePath, proxyAddr.String())
// Simulate legacy tsh client behavior where all clusters certs were stored in the certs.pem file.
require.NoError(t, os.RemoveAll(profile.TLSClusterCASDir()))
// Verify that authClient created from profile will create a valid client in case where cas dir doesn't exit.
mustCreateAuthClientFormUserProfile(t, tmpHomePath, proxyAddr.String())
}
type testServersOpts struct {
bootstrap []types.Resource
configFuncs []func(cfg *servicecfg.Config)
}
type testServerOptFunc func(o *testServersOpts)
func withBootstrap(bootstrap ...types.Resource) testServerOptFunc {
return func(o *testServersOpts) {
o.bootstrap = bootstrap
}
}
func withConfig(fn func(cfg *servicecfg.Config)) testServerOptFunc {
return func(o *testServersOpts) {
o.configFuncs = append(o.configFuncs, fn)
}
}
func withAuthConfig(fn func(*servicecfg.AuthConfig)) testServerOptFunc {
return withConfig(func(cfg *servicecfg.Config) {
fn(&cfg.Auth)
})
}
func withClusterName(t *testing.T, n string) testServerOptFunc {
return withAuthConfig(func(cfg *servicecfg.AuthConfig) {
clusterName, err := services.NewClusterNameWithRandomID(
types.ClusterNameSpecV2{
ClusterName: n,
})
require.NoError(t, err)
cfg.ClusterName = clusterName
})
}
func withMOTD(t *testing.T, motd string) testServerOptFunc {
oldStdin := prompt.Stdin()
t.Cleanup(func() {
prompt.SetStdin(oldStdin)
})
prompt.SetStdin(prompt.NewFakeReader().
AddString(""). // 3x to allow multiple logins
AddString("").
AddString(""))
return withAuthConfig(func(cfg *servicecfg.AuthConfig) {
fmt.Printf("\n\n Setting MOTD: '%s' \n\n", motd)
cfg.Preference.SetMessageOfTheDay(motd)
})
}
func withHostname(hostname string) testServerOptFunc {
return withConfig(func(cfg *servicecfg.Config) {
cfg.Hostname = hostname
})
}
func withSSHLabel(key, value string) testServerOptFunc {
return withConfig(func(cfg *servicecfg.Config) {
if cfg.SSH.Labels == nil {
cfg.SSH.Labels = make(map[string]string)
}
cfg.SSH.Labels[key] = value
})
}
// deprecated: Use `tools/teleport/testenv.NewTeleportProcess` instead.
func makeTestSSHNode(t *testing.T, authAddr *utils.NetAddr, opts ...testServerOptFunc) *service.TeleportProcess {
var options testServersOpts
for _, opt := range opts {
opt(&options)
}
// Set up a test ssh service.
cfg := servicecfg.MakeDefaultConfig()
cfg.CircuitBreakerConfig = breaker.NoopBreakerConfig()
cfg.Hostname = "node"
cfg.DataDir = t.TempDir()
cfg.SetAuthServerAddress(*authAddr)
cfg.SetToken(staticToken)
cfg.Auth.Enabled = false
cfg.Proxy.Enabled = false
cfg.SSH.Enabled = true
cfg.SSH.Addr = *utils.MustParseAddr("127.0.0.1:0")
cfg.SSH.PublicAddrs = []utils.NetAddr{cfg.SSH.Addr}
cfg.SSH.DisableCreateHostUser = true
cfg.Logger = logtest.NewLogger()
// Disabling debug service for tests so that it doesn't break if the data
// directory path is too long.
cfg.DebugService.Enabled = false
for _, fn := range options.configFuncs {
fn(cfg)
}
return runTeleport(t, cfg)
}
// deprecated: Use `tools/teleport/testenv.NewTeleportProcess` instead.
func makeTestServers(t *testing.T, opts ...testServerOptFunc) (auth *service.TeleportProcess, proxy *service.TeleportProcess) {
t.Helper()
var options testServersOpts
for _, opt := range opts {
opt(&options)
}
authAddr := utils.NetAddr{AddrNetwork: "tcp", Addr: net.JoinHostPort("127.0.0.1", ports.Pop())}
var err error
// Set up a test auth server.
cfg := servicecfg.MakeDefaultConfig()
cfg.CircuitBreakerConfig = breaker.NoopBreakerConfig()
cfg.Hostname = "localhost"
cfg.DataDir = t.TempDir()
cfg.SetAuthServerAddress(authAddr)
cfg.Auth.BootstrapResources = options.bootstrap
cfg.Auth.StorageConfig.Params = backend.Params{defaults.BackendPath: filepath.Join(cfg.DataDir, defaults.BackendDir)}
cfg.Auth.StaticTokens, err = types.NewStaticTokens(types.StaticTokensSpecV2{
StaticTokens: []types.ProvisionTokenV1{{
Roles: []types.SystemRole{types.RoleProxy, types.RoleDatabase, types.RoleTrustedCluster, types.RoleNode, types.RoleApp},
Expires: time.Now().Add(time.Minute),
Token: staticToken,
}},
})
require.NoError(t, err)
cfg.SetToken(staticToken)
cfg.SSH.Enabled = false
cfg.Auth.Enabled = true
cfg.Auth.ListenAddr = authAddr
cfg.Auth.Preference.SetSignatureAlgorithmSuite(types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1)
cfg.Proxy.Enabled = true
cfg.Proxy.WebAddr = utils.NetAddr{AddrNetwork: "tcp", Addr: net.JoinHostPort("127.0.0.1", ports.Pop())}
cfg.Proxy.SSHAddr = utils.NetAddr{AddrNetwork: "tcp", Addr: net.JoinHostPort("127.0.0.1", ports.Pop())}
cfg.Proxy.ReverseTunnelListenAddr = utils.NetAddr{AddrNetwork: "tcp", Addr: net.JoinHostPort("127.0.0.1", ports.Pop())}
cfg.Proxy.DisableWebInterface = true
cfg.Logger = logtest.NewLogger()
// Disabling debug service for tests so that it doesn't break if the data
// directory path is too long.
cfg.DebugService.Enabled = false
for _, fn := range options.configFuncs {
fn(cfg)
}
auth = runTeleport(t, cfg)
// Wait for auth to become ready.
_, err = auth.WaitForEventTimeout(30*time.Second, service.AuthTLSReady)
// in reality, the auth server should start *much* sooner than this. we use a very large
// timeout here because this isn't the kind of problem that this test is meant to catch.
require.NoError(t, err, "auth server didn't start after 30s")
return auth, auth
}
func mockConnector(t *testing.T) types.OIDCConnector {
// Connector need not be functional since we are going to mock the actual
// login operation.
connector, err := types.NewOIDCConnector("auth.example.com", types.OIDCConnectorSpecV3{
IssuerURL: "https://auth.example.com",
RedirectURLs: []string{"https://cluster.example.com"},
ClientID: "fake-client",
ClientSecret: "fake-secret",
ClaimsToRoles: []types.ClaimMapping{
{
Claim: "groups",
Value: "dummy",
Roles: []string{"dummy"},
},
},
})
require.NoError(t, err)
return connector
}
func mockSSOLogin(authServer *auth.Server, user types.User) client.SSOLoginFunc {
return func(ctx context.Context, connectorID string, keyRing *client.KeyRing, protocol string) (*authclient.CLILoginResponse, error) {
// generate certificates for our user
clusterName, err := authServer.GetClusterName(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
tlsPub, err := keyRing.TLSPrivateKey.MarshalTLSPublicKey()
if err != nil {
return nil, trace.Wrap(err)
}
sshCert, tlsCert, err := authServer.GenerateUserTestCertsWithContext(ctx, auth.GenerateUserTestCertsRequest{
SSHPubKey: keyRing.SSHPrivateKey.MarshalSSHPublicKey(),
TLSPubKey: tlsPub,
Username: user.GetName(),
TTL: time.Hour,
Compatibility: constants.CertificateFormatStandard,
RouteToCluster: clusterName.GetClusterName(),
SSHAttestationStatement: keyRing.SSHPrivateKey.GetAttestationStatement(),
TLSAttestationStatement: keyRing.TLSPrivateKey.GetAttestationStatement(),
})
if err != nil {
return nil, trace.Wrap(err)
}
// load CA cert
authority, err := authServer.GetCertAuthority(ctx, types.CertAuthID{
Type: types.HostCA,
DomainName: clusterName.GetClusterName(),
}, false)
if err != nil {
return nil, trace.Wrap(err)
}
// build login response
return &authclient.CLILoginResponse{
Username: user.GetName(),
Cert: sshCert,
TLSCert: tlsCert,
HostSigners: authclient.AuthoritiesToTrustedCerts([]types.CertAuthority{authority}),
}, nil
}
}
func mockHeadlessLogin(t *testing.T, authServer *auth.Server, user types.User) client.SSHLoginFunc {
return func(ctx context.Context, keyRing *client.KeyRing) (*authclient.CLILoginResponse, error) {
// generate certificates for our user
clusterName, err := authServer.GetClusterName(ctx)
require.NoError(t, err)
tlsPub, err := keyRing.TLSPrivateKey.MarshalTLSPublicKey()
require.NoError(t, err)
sshCert, tlsCert, err := authServer.GenerateUserTestCertsWithContext(ctx, auth.GenerateUserTestCertsRequest{
SSHPubKey: keyRing.SSHPrivateKey.MarshalSSHPublicKey(),
TLSPubKey: tlsPub,
Username: user.GetName(),
TTL: time.Hour,
Compatibility: constants.CertificateFormatStandard,
RouteToCluster: clusterName.GetClusterName(),
MFAVerified: "mfa-verified",
})
require.NoError(t, err)
// load CA cert
authority, err := authServer.GetCertAuthority(ctx, types.CertAuthID{
Type: types.HostCA,
DomainName: clusterName.GetClusterName(),
}, false)
require.NoError(t, err)
// build login response
return &authclient.CLILoginResponse{
Username: user.GetName(),
Cert: sshCert,
TLSCert: tlsCert,
HostSigners: authclient.AuthoritiesToTrustedCerts([]types.CertAuthority{authority}),
}, nil
}
}
func setOverrideStdout(stdout io.Writer) CliOption {
return func(cf *CLIConf) error {
cf.OverrideStdout = stdout
return nil
}
}
func setCopyStdout(stdout io.Writer) CliOption {
return setOverrideStdout(io.MultiWriter(os.Stdout, stdout))
}
func setHomePath(path string) CliOption {
return func(cf *CLIConf) error {
cf.HomePath = path
// The TSHConfig is populated prior to applying any options, and
// if the home directory is being overridden, then any proxy templates
// and aliases that may have been loaded from the default home directory
// should be returned to default to avoid altering tests run from machines
// that may have a tsh config file present in the home directory.
cf.TSHConfig = client.TSHConfig{}
return nil
}
}
func setTSHConfig(cfg client.TSHConfig) CliOption {
return func(cf *CLIConf) error {
for _, template := range cfg.ProxyTemplates {
if err := template.Check(); err != nil {
return err
}
}
cf.TSHConfig = cfg
return nil
}
}
func setNoRelogin() CliOption {
return func(cf *CLIConf) error {
cf.Relogin = false
return nil
}
}
func setOverrideMySQLConfigPath(path string) CliOption {
return func(cf *CLIConf) error {
cf.overrideMySQLOptionFilePath = path
return nil
}
}
func setOverridePostgresConfigPath(path string) CliOption {
return func(cf *CLIConf) error {
cf.overridePostgresServiceFilePath = path
return nil
}
}
func setKubeConfigPath(path string) CliOption {
return func(cf *CLIConf) error {
cf.KubeConfigPath = path
return nil
}
}
func setIdentity(path string) CliOption {
return func(cf *CLIConf) error {
cf.IdentityFileIn = path
return nil
}
}
func setCmdRunner(cmdRunner func(*exec.Cmd) error) CliOption {
return func(cf *CLIConf) error {
cf.cmdRunner = cmdRunner
return nil
}
}
func setMockSSOLogin(authServer *auth.Server, user types.User, connectorName string) CliOption {
return setMockSSOLoginCustom(mockSSOLogin(authServer, user), connectorName)
}
func setMockSSOLoginCustom(mockSSOLogin client.SSOLoginFunc, connectorName string) CliOption {
return func(cf *CLIConf) error {
cf.MockSSOLogin = mockSSOLogin
cf.AuthConnector = connectorName
return nil
}
}
func testSerialization(t *testing.T, expected string, serializer func(string) (string, error)) {
t.Helper()
out, err := serializer(teleport.JSON)
require.NoError(t, err)
require.JSONEq(t, expected, out)
out, err = serializer(teleport.YAML)
require.NoError(t, err)
outJSON, err := yaml.YAMLToJSON([]byte(out))
require.NoError(t, err)
require.JSONEq(t, expected, string(outJSON))
}
func TestSerializeVersion(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
expected string
proxyVersion string
proxyPublicAddress string
reExecFromVersion string
}{
{
name: "no proxy version provided",
expected: fmt.Sprintf(
`{"version": %q, "gitref": %q, "runtime": %q}`,
teleport.Version, teleport.Gitref, runtime.Version(),
),
},
{
name: "proxy version provided",
proxyVersion: "1.33.7",
proxyPublicAddress: "teleport.example.com:443",
expected: fmt.Sprintf(
`{"version": %q, "gitref": %q, "runtime": %q, "proxyVersion": %q, "proxyPublicAddress": %q}`,
teleport.Version, teleport.Gitref, runtime.Version(), "1.33.7", "teleport.example.com:443"),
},
{
name: "re-exec version provided",
proxyVersion: "3.2.1",
proxyPublicAddress: "teleport.example.com:443",
reExecFromVersion: "1.2.3",
expected: fmt.Sprintf(
`{"version": %q, "gitref": %q, "runtime": %q, "proxyVersion": %q, "reExecutedFromVersion": %q, "proxyPublicAddress": %q}`,
teleport.Version, teleport.Gitref, runtime.Version(), "3.2.1", "1.2.3", "teleport.example.com:443"),
},
}
for _, tC := range testCases {
t.Run(tC.name, func(t *testing.T) {
testSerialization(t, tC.expected, func(fmt string) (string, error) {
return serializeVersion(fmt, tC.proxyVersion, tC.proxyPublicAddress, tC.reExecFromVersion)
})
})
}
}
func TestSerializeApps(t *testing.T) {
t.Parallel()
expected := `
[{
"kind": "app",
"version": "v3",
"metadata": {
"name": "my app",
"description": "this is the description",
"labels": {
"a": "1",
"b": "2"
}
},
"spec": {
"uri": "https://example.com",
"insecure_skip_verify": false
}
}]
`
app, err := types.NewAppV3(types.Metadata{
Name: "my app",
Description: "this is the description",
Labels: map[string]string{"a": "1", "b": "2"},
}, types.AppSpecV3{
URI: "https://example.com",
})
require.NoError(t, err)
testSerialization(t, expected, func(f string) (string, error) {
return serializeApps([]types.Application{app}, f)
})
}
func TestSerializeAppsEmpty(t *testing.T) {
t.Parallel()
testSerialization(t, "[]", func(f string) (string, error) {
return serializeApps(nil, f)
})
}
func TestSerializeAppConfig(t *testing.T) {
t.Parallel()
expected := `
{
"name": "my app",
"uri": "https://example.com",
"ca": "/path/to/ca",
"cert": "/path/to/cert",
"key": "/path/to/key",
"curl": "curl https://example.com"
}
`
appConfig := &appConfigInfo{
Name: "my app",
URI: "https://example.com",
CA: "/path/to/ca",
Cert: "/path/to/cert",
Key: "/path/to/key",
Curl: "curl https://example.com",
}
testSerialization(t, expected, func(f string) (string, error) {
return serializeAppConfig(appConfig, f)
})
}
func TestSerializeDatabases(t *testing.T) {
t.Parallel()
expectedFmt := `
[{
"kind": "db",
"version": "v3",
"metadata": {
"name": "my-db",
"description": "this is the description",
"labels": {"a": "1", "b": "2"}
},
"spec": {
"protocol": "mongodb",
"uri": "mongodb://example.com",
"aws": {
"redshift": {},
"rds": {
"iam_auth": false
},
"iam_policy_status": "IAM_POLICY_STATUS_UNSPECIFIED",
"elasticache": {},
"elasticache_serverless": {},
"secret_store": {},
"memorydb": {},
"opensearch": {},
"rdsproxy": {},
"redshift_serverless": {},
"docdb": {}
},
"mysql": {},
"oracle": {},
"gcp": {
"alloydb": {}
},
"azure": {
"redis": {}
},
"tls": {
"mode": 0
},
"ad": {
"domain": "",
"spn": ""
},
"mongo_atlas": {}
},
"status": {
"mysql": {},
"aws": {
"redshift": {},
"rds": {
"iam_auth": false
},
"iam_policy_status": "IAM_POLICY_STATUS_UNSPECIFIED",
"elasticache": {},
"elasticache_serverless": {},
"secret_store": {},
"memorydb": {},
"opensearch": {},
"rdsproxy": {},
"redshift_serverless": {},
"docdb": {}
},
"azure": {
"redis": {}
}
}%v
}]
`
db, err := types.NewDatabaseV3(types.Metadata{
Name: "my-db",
Description: "this is the description",
Labels: map[string]string{"a": "1", "b": "2"},
}, types.DatabaseSpecV3{
Protocol: "mongodb",
URI: "mongodb://example.com",
})
require.NoError(t, err)
tests := []struct {
name string
dbUsersData string
roles services.RoleSet
}{
{
name: "without db users",
},
{
name: "with wildcard in allowed db users",
dbUsersData: `,
"users": {
"allowed": [
"*",
"bar",
"foo"
],
"denied": [
"baz",
"qux"
]
}`,
roles: services.RoleSet{
&types.RoleV6{
Metadata: types.Metadata{Name: "role1", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"*": []string{"*"}},
DatabaseUsers: []string{"foo", "bar", "*"},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{"baz", "qux"},
},
},
},
},
},
{
name: "without wildcard in allowed db users",
dbUsersData: `,
"users": {
"allowed": [
"bar",
"foo"
]
}`,
roles: services.RoleSet{
&types.RoleV6{
Metadata: types.Metadata{Name: "role2", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"*": []string{"*"}},
DatabaseUsers: []string{"foo", "bar"},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{"baz", "qux"},
},
},
},
},
},
{
name: "with no denied db users",
dbUsersData: `,
"users": {
"allowed": [
"*",
"bar",
"foo"
]
}`,
roles: services.RoleSet{
&types.RoleV6{
Metadata: types.Metadata{Name: "role2", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"*": []string{"*"}},
DatabaseUsers: []string{"*", "foo", "bar"},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{},
},
},
},
},
},
{
name: "with no allowed db users",
dbUsersData: `,
"users": {}`,
roles: services.RoleSet{
&types.RoleV6{
Metadata: types.Metadata{Name: "role2", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"*": []string{"*"}},
DatabaseUsers: []string{},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{"baz", "qux"},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
accessChecker := services.NewAccessCheckerWithRoleSet(&services.AccessInfo{}, "clustername", tt.roles)
expected := fmt.Sprintf(expectedFmt, tt.dbUsersData)
testSerialization(t, expected, func(f string) (string, error) {
return serializeDatabases([]types.Database{db}, f, accessChecker)
})
})
}
}
func TestSerializeDatabasesEmpty(t *testing.T) {
t.Parallel()
testSerialization(t, "[]", func(f string) (string, error) {
return serializeDatabases(nil, f, nil)
})
}
func TestSerializeDatabaseEnvironment(t *testing.T) {
t.Parallel()
expected := `
{
"A": "1",
"B": "2"
}
`
env := map[string]string{
"A": "1",
"B": "2",
}
testSerialization(t, expected, func(f string) (string, error) {
return serializeDatabaseEnvironment(env, f)
})
}
func TestSerializeDatabaseConfig(t *testing.T) {
t.Parallel()
expected := `
{
"name": "my db",
"host": "example.com",
"port": 27017,
"ca": "/path/to/ca",
"cert": "/path/to/cert",
"key": "/path/to/key"
}
`
configInfo := &dbConfigInfo{
Name: "my db",
Host: "example.com",
Port: 27017,
CA: "/path/to/ca",
Cert: "/path/to/cert",
Key: "/path/to/key",
}
testSerialization(t, expected, func(f string) (string, error) {
return serializeDatabaseConfig(configInfo, f)
})
}
func TestSerializeNodes(t *testing.T) {
t.Parallel()
expected := `
[{
"kind": "node",
"version": "v2",
"metadata": {
"name": "my server"
},
"spec": {
"addr": "https://example.com",
"hostname": "example.com",
"rotation": {
"current_id": "",
"started": "0001-01-01T00:00:00Z",
"last_rotated": "0001-01-01T00:00:00Z",
"schedule": {
"update_clients": "0001-01-01T00:00:00Z",
"update_servers": "0001-01-01T00:00:00Z",
"standby": "0001-01-01T00:00:00Z"
}
},
"version": "v2"
}
}]
`
node, err := types.NewServer("my server", "node", types.ServerSpecV2{
Addr: "https://example.com",
Hostname: "example.com",
Version: "v2",
})
require.NoError(t, err)
testSerialization(t, expected, func(f string) (string, error) {
return serializeNodes([]types.Server{node}, f)
})
}
func TestSerializeNodesEmpty(t *testing.T) {
t.Parallel()
testSerialization(t, "[]", func(f string) (string, error) {
return serializeNodes(nil, f)
})
}
func TestSerializeClusters(t *testing.T) {
t.Parallel()
expected := `
[
{
"cluster_name": "rootCluster",
"status": "online",
"cluster_type": "root",
"labels": null,
"selected": true
},
{
"cluster_name": "leafCluster",
"status": "offline",
"cluster_type": "leaf",
"labels": {"foo": "bar", "baz": "boof"},
"selected": false
}
]
`
root := clusterInfo{
ClusterName: "rootCluster",
Status: teleport.RemoteClusterStatusOnline,
ClusterType: "root",
Selected: true,
}
leafClusters := []clusterInfo{
{
ClusterName: "leafCluster",
Status: teleport.RemoteClusterStatusOffline,
ClusterType: "leaf",
Labels: map[string]string{
"foo": "bar",
"baz": "boof",
},
Selected: false,
},
}
testSerialization(t, expected, func(f string) (string, error) {
return serializeClusters(root, leafClusters, f)
})
}
func TestSerializeProfiles(t *testing.T) {
t.Parallel()
expected := `
{
"active": {
"profile_url": "example.com",
"username": "test",
"active_requests": [
"1",
"2",
"3"
],
"cluster": "main",
"roles": [
"a",
"b",
"c"
],
"traits": {
"a": [
"1",
"2",
"3"
]
},
"logins": [
"a",
"b",
"c"
],
"kubernetes_enabled": true,
"kubernetes_users": [
"x"
],
"kubernetes_groups": [
"y"
],
"databases": [
"z"
],
"valid_until": "1970-01-01T00:00:00Z",
"extensions": [
"7",
"8",
"9"
]
},
"profiles": [
{
"profile_url": "example.com",
"username": "test2",
"cluster": "other",
"kubernetes_enabled": false,
"valid_until": "1970-01-01T00:00:00Z"
}
]
}
`
aTime := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
p, err := url.Parse("example.com")
require.NoError(t, err)
activeProfile := &client.ProfileStatus{
ProxyURL: *p,
Username: "test",
ActiveRequests: []string{"1", "2", "3"},
Cluster: "main",
Roles: []string{"a", "b", "c"},
Traits: wrappers.Traits{"a": []string{"1", "2", "3"}},
Logins: []string{"a", "b", "c"},
KubeEnabled: true,
KubeUsers: []string{"x"},
KubeGroups: []string{"y"},
Databases: []tlsca.RouteToDatabase{{ServiceName: "z"}},
ValidUntil: aTime,
Extensions: []string{"7", "8", "9"},
}
otherProfile := &client.ProfileStatus{
ProxyURL: *p,
Username: "test2",
Cluster: "other",
ValidUntil: aTime,
}
testSerialization(t, expected, func(f string) (string, error) {
activeInfo, othersInfo := makeAllProfileInfo(activeProfile, []*client.ProfileStatus{otherProfile}, nil)
return serializeProfiles(activeInfo, othersInfo, nil, f)
})
}
func TestSerializeProfilesNoOthers(t *testing.T) {
t.Parallel()
expected := `
{
"active": {
"profile_url": "example.com",
"username": "test",
"cluster": "main",
"kubernetes_enabled": false,
"valid_until": "1970-01-01T00:00:00Z"
},
"profiles": []
}`
aTime := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
p, err := url.Parse("example.com")
require.NoError(t, err)
profile := &client.ProfileStatus{
ProxyURL: *p,
Username: "test",
Cluster: "main",
ValidUntil: aTime,
}
testSerialization(t, expected, func(f string) (string, error) {
active, _ := makeAllProfileInfo(profile, nil, nil)
return serializeProfiles(active, nil, nil, f)
})
}
func TestSerializeProfilesNoActive(t *testing.T) {
t.Parallel()
expected := `
{
"profiles": []
}
`
testSerialization(t, expected, func(f string) (string, error) {
return serializeProfiles(nil, nil, nil, f)
})
}
func TestSerializeProfilesWithEnvVars(t *testing.T) {
cluster := "someCluster"
siteName := "someSiteName"
proxy := "example.com"
kubeCluster := "someKubeCluster"
kubeConfig := "someKubeConfigPath"
t.Setenv(proxyEnvVar, proxy)
t.Setenv(clusterEnvVar, cluster)
t.Setenv(siteEnvVar, siteName)
t.Setenv(kubeClusterEnvVar, kubeCluster)
t.Setenv(teleport.EnvKubeConfig, kubeConfig)
expected := fmt.Sprintf(`
{
"profiles": [],
"environment": {
%q: %q,
%q: %q,
%q: %q,
%q: %q,
%q: %q
}
}
`, teleport.EnvKubeConfig, kubeConfig,
clusterEnvVar, cluster,
kubeClusterEnvVar, kubeCluster,
proxyEnvVar, proxy,
siteEnvVar, siteName)
testSerialization(t, expected, func(f string) (string, error) {
env := getTshEnv()
return serializeProfiles(nil, nil, env, f)
})
}
func TestSerializeEnvironment(t *testing.T) {
t.Parallel()
expected := fmt.Sprintf(`
{
%q: "example.com",
%q: "main"
}
`, proxyEnvVar, clusterEnvVar)
p, err := url.Parse("https://example.com")
require.NoError(t, err)
profile := &client.ProfileStatus{
ProxyURL: *p,
Cluster: "main",
}
testSerialization(t, expected, func(f string) (string, error) {
return serializeEnvironment(profile, f)
})
}
func TestSerializeAccessRequests(t *testing.T) {
t.Parallel()
expected := `
{
"kind": "access_request",
"version": "v3",
"metadata": {
"name": "test"
},
"spec": {
"user": "user",
"roles": [
"a",
"b",
"c"
],
"state": 1,
"created": "0001-01-01T00:00:00Z",
"expires": "0001-01-01T00:00:00Z",
"max_duration": "0001-01-01T00:00:00Z",
"session_ttl": "0001-01-01T00:00:00Z"
}
}`
req, err := types.NewAccessRequest("test", "user", "a", "b", "c")
require.NoError(t, err)
testSerialization(t, expected, func(f string) (string, error) {
return serializeAccessRequest(req, f)
})
expected2 := fmt.Sprintf("[%v]", expected)
testSerialization(t, expected2, func(f string) (string, error) {
return serializeAccessRequests([]types.AccessRequest{req}, f)
})
}
func TestSerializeKubeSessions(t *testing.T) {
t.Parallel()
aTime := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
expected := `
[
{
"kind": "session_tracker",
"version": "v1",
"metadata": {
"name": "id",
"expires": "1970-01-01T00:00:00Z"
},
"spec": {
"session_id": "id",
"kind": "session-kind",
"state": 1,
"created": "1970-01-01T00:00:00Z",
"expires": "1970-01-01T00:00:00Z",
"attached": "arbitrary attached data",
"reason": "some reason",
"invited": [
"a",
"b",
"c"
],
"target_hostname": "example.com",
"target_address": "https://example.com",
"cluster_name": "cluster",
"login": "login",
"participants": [
{
"id": "some-id",
"user": "test",
"mode": "mode",
"last_active": "1970-01-01T00:00:00Z"
}
],
"kubernetes_cluster": "kc",
"host_user": "test",
"host_roles": [
{
"name": "policy",
"version": "v1",
"require_session_join": [
{
"name": "policy",
"filter": "filter",
"kinds": [
"x",
"y",
"z"
],
"count": 1,
"modes": [
"mode",
"mode-1",
"mode-2"
],
"on_leave": "do something"
}
]
}
]
}
}
]`
tracker, err := types.NewSessionTracker(types.SessionTrackerSpecV1{
SessionID: "id",
Kind: "session-kind",
State: types.SessionState_SessionStateRunning,
Created: aTime,
Expires: aTime,
AttachedData: "arbitrary attached data",
Reason: "some reason",
Invited: []string{"a", "b", "c"},
Hostname: "example.com",
Address: "https://example.com",
ClusterName: "cluster",
Login: "login",
Participants: []types.Participant{
{
ID: "some-id",
User: "test",
Mode: "mode",
LastActive: aTime,
},
},
KubernetesCluster: "kc",
HostUser: "test",
HostPolicies: []*types.SessionTrackerPolicySet{
{
Name: "policy",
Version: "v1",
RequireSessionJoin: []*types.SessionRequirePolicy{
{
Name: "policy",
Filter: "filter",
Kinds: []string{"x", "y", "z"},
Count: 1,
Modes: []string{"mode", "mode-1", "mode-2"},
OnLeave: "do something",
},
},
},
},
})
require.NoError(t, err)
testSerialization(t, expected, func(f string) (string, error) {
var b bytes.Buffer
err := serializeSessions([]types.SessionTracker{tracker}, f, &b)
return b.String(), err
})
}
func TestSerializeKubeClusters(t *testing.T) {
t.Parallel()
expected := `
[
{
"kube_cluster_name": "cluster1",
"labels": {"cmd": "result", "foo": "bar"},
"selected": true
},
{
"kube_cluster_name": "cluster2",
"labels": null,
"selected": false
}
]
`
c1, err := types.NewKubernetesClusterV3(
types.Metadata{
Name: "cluster1",
Labels: map[string]string{
"foo": "bar",
},
},
types.KubernetesClusterSpecV3{
DynamicLabels: map[string]types.CommandLabelV2{
"cmd": {
Result: "result",
},
},
},
)
require.NoError(t, err)
c2, err := types.NewKubernetesClusterV3(
types.Metadata{
Name: "cluster2",
},
types.KubernetesClusterSpecV3{},
)
require.NoError(t, err)
clusters := []types.KubeCluster{
c1, c2,
}
testSerialization(t, expected, func(f string) (string, error) {
return serializeKubeClusters(clusters, "cluster1", f)
})
}
func TestSerializeMFADevices(t *testing.T) {
t.Parallel()
aTime := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
expected := `
[
{"metadata":{"Name":"my device","Namespace":"default"},"id":"id","addedAt":"1970-01-01T00:00:00Z","lastUsed":"1970-01-01T00:00:00Z","kind":"mfa_device","totp":{"key":"key"},"version":"v1"}
]
`
dev, err := types.NewMFADevice("my device", "id", aTime, &types.MFADevice_Totp{
Totp: &types.TOTPDevice{
Key: "key",
},
})
require.NoError(t, err)
testSerialization(t, expected, func(f string) (string, error) {
return serializeMFADevices([]*types.MFADevice{dev}, f)
})
}
func TestListDatabasesWithUsers(t *testing.T) {
t.Parallel()
dbStage, err := types.NewDatabaseV3(types.Metadata{
Name: "stage",
Labels: map[string]string{"env": "stage"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
dbProd, err := types.NewDatabaseV3(types.Metadata{
Name: "prod",
Labels: map[string]string{"env": "prod"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
dbWithAutoUser, err := types.NewDatabaseV3(types.Metadata{
Name: "auto-user",
Labels: map[string]string{"env": "prod"},
}, types.DatabaseSpecV3{
Protocol: "postgres",
URI: "localhost:5432",
AdminUser: &types.DatabaseAdminUser{
Name: "teleport-admin",
},
})
require.NoError(t, err)
roleDevStage := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-stage", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"stage"}},
DatabaseUsers: []string{types.Wildcard},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{"superuser"},
},
},
}
roleDevProd := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-prod", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"prod"}},
DatabaseUsers: []string{"dev"},
},
},
}
roleAutoUser := &types.RoleV6{
Metadata: types.Metadata{Name: "auto-user", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"prod"}},
DatabaseRoles: []string{"dev"},
DatabaseNames: []string{"*"},
DatabaseUsers: []string{types.Wildcard},
},
},
}
tests := []struct {
name string
roles services.RoleSet
database types.Database
wantUsers *dbUsers
wantText string
}{
{
name: "developer allowed any username in stage database except superuser",
roles: services.RoleSet{roleDevStage, roleDevProd},
database: dbStage,
wantUsers: &dbUsers{
Allowed: []string{"*"},
Denied: []string{"superuser"},
},
wantText: "[*], except: [superuser]",
},
{
name: "developer allowed only specific username/database in prod database",
roles: services.RoleSet{roleDevStage, roleDevProd},
database: dbProd,
wantUsers: &dbUsers{
Allowed: []string{"dev"},
},
wantText: "[dev]",
},
{
name: "roleDevStage x dbStage",
roles: services.RoleSet{roleDevStage},
database: dbStage,
wantUsers: &dbUsers{
Allowed: []string{"*"},
Denied: []string{"superuser"},
},
wantText: "[*], except: [superuser]",
},
{
name: "roleDevStage x dbProd",
roles: services.RoleSet{roleDevStage},
database: dbProd,
wantUsers: &dbUsers{},
wantText: "(none)",
},
{
name: "roleDevProd x dbStage",
roles: services.RoleSet{roleDevProd},
database: dbStage,
wantUsers: &dbUsers{},
wantText: "(none)",
},
{
name: "roleDevProd x dbProd",
roles: services.RoleSet{roleDevProd},
database: dbProd,
wantUsers: &dbUsers{
Allowed: []string{"dev"},
},
wantText: "[dev]",
},
{
name: "db with admin user and role with auto-user",
database: dbWithAutoUser,
roles: services.RoleSet{roleAutoUser},
wantUsers: &dbUsers{
Allowed: []string{"alice"},
},
wantText: "[alice] (Auto-provisioned)",
},
{
name: "db with admin user but role without auto-user",
database: dbWithAutoUser,
roles: services.RoleSet{roleDevProd},
wantUsers: &dbUsers{
Allowed: []string{"dev"},
},
wantText: "[dev]",
},
{
name: "db without admin user but role with auto-user",
database: dbProd,
roles: services.RoleSet{roleAutoUser},
wantUsers: &dbUsers{
Allowed: []string{"*"},
},
wantText: "[*]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
accessChecker := services.NewAccessCheckerWithRoleSet(&services.AccessInfo{
Username: "alice",
}, "clustername", tt.roles)
gotUsers := getDBUsers(tt.database, accessChecker)
require.Equal(t, tt.wantUsers, gotUsers)
gotText := formatUsersForDB(tt.database, accessChecker)
require.Equal(t, tt.wantText, gotText)
})
}
}
func spanAssertion(containsTSH, empty bool) func(t *testing.T, spans []*otlp.ScopeSpans) {
return func(t *testing.T, spans []*otlp.ScopeSpans) {
if empty {
require.Empty(t, spans)
return
}
require.NotEmpty(t, spans)
var scopes []string
for _, span := range spans {
scopes = append(scopes, span.Scope.Name)
}
if containsTSH {
require.Contains(t, scopes, teleport.ComponentTSH)
} else {
require.NotContains(t, scopes, teleport.ComponentTSH)
}
}
}
func TestForwardingTraces(t *testing.T) {
cases := []struct {
name string
cfg func(c *tracing.Collector) servicecfg.TracingConfig
spanAssertion func(t *testing.T, spans []*otlp.ScopeSpans)
}{
{
name: "spans exported with auth sampling all",
cfg: func(c *tracing.Collector) servicecfg.TracingConfig {
return servicecfg.TracingConfig{
Enabled: true,
ExporterURL: c.GRPCAddr(),
SamplingRate: 1.0,
}
},
spanAssertion: spanAssertion(true, false),
},
{
name: "spans exported with auth sampling none",
cfg: func(c *tracing.Collector) servicecfg.TracingConfig {
return servicecfg.TracingConfig{
Enabled: true,
ExporterURL: c.HTTPAddr(),
SamplingRate: 0.0,
}
},
spanAssertion: spanAssertion(true, false),
},
{
name: "spans not exported when tracing disabled",
cfg: func(c *tracing.Collector) servicecfg.TracingConfig {
return servicecfg.TracingConfig{}
},
spanAssertion: func(t *testing.T, spans []*otlp.ScopeSpans) {
require.Empty(t, spans)
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
collector, err := tracing.NewCollector(tracing.CollectorConfig{})
require.NoError(t, err)
errCh := make(chan error)
go func() {
errCh <- collector.Start()
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
require.NoError(t, collector.Shutdown(ctx))
require.NoError(t, <-errCh)
})
traceCfg := tt.cfg(collector)
authProcess, proxyProcess := makeTestServers(t,
withBootstrap(connector, alice),
withConfig(func(cfg *servicecfg.Config) {
cfg.Tracing = traceCfg
}),
)
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
// --trace should cause the login to be traced.
err = Run(context.Background(), []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
"--trace",
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()))
require.NoError(t, err)
if traceCfg.Enabled {
collector.WaitForExport()
}
// Ensure login spans from tsh are forwarded to the exporter if
// tracing is enabled on the auth service.
loginAssertion := spanAssertion(true, !traceCfg.Enabled)
loginAssertion(t, collector.Spans())
err = Run(context.Background(), []string{
"ls",
"--insecure",
"--proxy", proxyAddr.String(),
"--trace",
}, setHomePath(tmpHomePath))
require.NoError(t, err)
if traceCfg.Enabled {
collector.WaitForExport()
}
tt.spanAssertion(t, collector.Spans())
})
}
}
func TestExportingTraces(t *testing.T) {
cases := []struct {
name string
cfg func(c *tracing.Collector) servicecfg.TracingConfig
teleportSpanAssertion func(t *testing.T, spans []*otlp.ScopeSpans)
tshSpanAssertion func(t *testing.T, spans []*otlp.ScopeSpans)
}{
{
name: "spans exported with auth sampling all",
cfg: func(c *tracing.Collector) servicecfg.TracingConfig {
return servicecfg.TracingConfig{
Enabled: true,
ExporterURL: c.GRPCAddr(),
SamplingRate: 1.0,
}
},
teleportSpanAssertion: spanAssertion(false, false),
tshSpanAssertion: spanAssertion(true, false),
},
{
name: "spans exported with auth sampling none",
cfg: func(c *tracing.Collector) servicecfg.TracingConfig {
return servicecfg.TracingConfig{
Enabled: true,
ExporterURL: c.HTTPAddr(),
SamplingRate: 0.0,
}
},
teleportSpanAssertion: spanAssertion(false, false),
tshSpanAssertion: spanAssertion(true, false),
},
{
name: "spans not exported when tracing disabled",
cfg: func(c *tracing.Collector) servicecfg.TracingConfig {
return servicecfg.TracingConfig{}
},
teleportSpanAssertion: spanAssertion(false, true),
tshSpanAssertion: spanAssertion(true, false),
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
tmpHomePath := t.TempDir()
teleportCollector, err := tracing.NewCollector(tracing.CollectorConfig{})
require.NoError(t, err)
tshCollector, err := tracing.NewCollector(tracing.CollectorConfig{})
require.NoError(t, err)
errCh := make(chan error, 2)
go func() {
errCh <- teleportCollector.Start()
}()
go func() {
errCh <- tshCollector.Start()
}()
traceCfg := tt.cfg(teleportCollector)
authProcess, proxyProcess := makeTestServers(t,
withBootstrap(connector, alice),
withConfig(func(cfg *servicecfg.Config) {
cfg.Tracing = traceCfg
}),
)
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
// login events should be included since there is
// no forwarding
err = Run(context.Background(), []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
"--trace",
"--trace-exporter", tshCollector.GRPCAddr(),
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()))
require.NoError(t, err)
if traceCfg.Enabled {
teleportCollector.WaitForExport()
}
tshCollector.WaitForExport()
tt.teleportSpanAssertion(t, teleportCollector.Spans())
tt.tshSpanAssertion(t, tshCollector.Spans())
err = Run(context.Background(), []string{
"ls",
"--insecure",
"--proxy", proxyAddr.String(),
"--trace",
"--trace-exporter", tshCollector.GRPCAddr(),
}, setHomePath(tmpHomePath))
require.NoError(t, err)
if traceCfg.Enabled {
teleportCollector.WaitForExport()
}
tshCollector.WaitForExport()
tt.teleportSpanAssertion(t, teleportCollector.Spans())
tt.tshSpanAssertion(t, tshCollector.Spans())
})
}
}
func TestShowSessions(t *testing.T) {
t.Parallel()
expected := `[
{
"ei": 0,
"event": "",
"uid": "someID1",
"time": "0001-01-01T00:00:00Z",
"sid": "",
"server_id": "",
"enhanced_recording": false,
"interactive": false,
"participants": [
"someParticipant"
],
"session_start": "0001-01-01T00:00:00Z",
"session_stop": "0001-01-01T00:00:00Z"
},
{
"ei": 0,
"event": "",
"uid": "someID2",
"time": "0001-01-01T00:00:00Z",
"sid": "",
"server_id": "",
"enhanced_recording": false,
"interactive": false,
"participants": [
"someParticipant"
],
"session_start": "0001-01-01T00:00:00Z",
"session_stop": "0001-01-01T00:00:00Z"
},
{
"ei": 0,
"event": "",
"uid": "someID3",
"time": "0001-01-01T00:00:00Z",
"sid": "",
"windows_desktop_service": "",
"desktop_addr": "",
"windows_domain": "",
"windows_user": "",
"desktop_labels": null,
"session_start": "0001-01-01T00:00:00Z",
"session_stop": "0001-01-01T00:00:00Z",
"desktop_name": "",
"recorded": false,
"participants": [
"someParticipant"
]
},
{
"ei": 0,
"event": "",
"uid": "someID4",
"time": "0001-01-01T00:00:00Z",
"user": "someUser",
"sid": "",
"db_protocol": "postgres",
"db_uri": "",
"session_start": "0001-01-01T00:00:00Z",
"session_stop": "0001-01-01T00:00:00Z",
"participants": ["someParticipant"]
},
{
"app_name": "someApp",
"ei": 0,
"event": "",
"server_id": "",
"session_chunk_id": "someChunkID",
"sid": "",
"time": "0001-01-01T00:00:00Z",
"uid": "someID5",
"user": "someUser"
} ]`
sessions := []events.AuditEvent{
&events.SessionEnd{
Metadata: events.Metadata{
ID: "someID1",
},
StartTime: time.Time{},
EndTime: time.Time{},
Participants: []string{"someParticipant"},
},
&events.SessionEnd{
Metadata: events.Metadata{
ID: "someID2",
},
StartTime: time.Time{},
EndTime: time.Time{},
Participants: []string{"someParticipant"},
},
&events.WindowsDesktopSessionEnd{
Metadata: events.Metadata{
ID: "someID3",
},
StartTime: time.Time{},
EndTime: time.Time{},
Participants: []string{"someParticipant"},
},
&events.DatabaseSessionEnd{
Metadata: events.Metadata{
ID: "someID4",
},
UserMetadata: events.UserMetadata{
User: "someUser",
},
DatabaseMetadata: events.DatabaseMetadata{
DatabaseProtocol: "postgres",
},
StartTime: time.Time{},
EndTime: time.Time{},
Participants: []string{"someParticipant"},
},
&events.AppSessionChunk{
Metadata: events.Metadata{
ID: "someID5",
},
UserMetadata: events.UserMetadata{
User: "someUser",
},
AppMetadata: events.AppMetadata{
AppName: "someApp",
},
SessionChunkID: "someChunkID",
},
}
var buf bytes.Buffer
err := common.ShowSessions(sessions, teleport.JSON, &buf)
require.NoError(t, err)
require.JSONEq(t, expected, buf.String())
}
func TestMakeProfileInfo_NoInternalLogins(t *testing.T) {
t.Parallel()
tests := []struct {
name string
profile *client.ProfileStatus
expectedLogins []string
}{
{
name: "with internal logins",
profile: &client.ProfileStatus{
Logins: []string{constants.NoLoginPrefix, teleport.SSHSessionJoinPrincipal, "-teleport-something-else"},
},
expectedLogins: nil,
},
{
name: "with valid logins and internal logins",
profile: &client.ProfileStatus{
Logins: []string{constants.NoLoginPrefix, "alpaca", teleport.SSHSessionJoinPrincipal, "llama"},
},
expectedLogins: []string{"alpaca", "llama"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
madeProfile := makeProfileInfo(test.profile, nil /* env map */, false /* inactive */)
require.Equal(t, test.expectedLogins, madeProfile.Logins)
})
}
}
func TestBenchmarkPostgres(t *testing.T) {
// Canceling the context right after the test ensures the local proxy
// started by the benchmark command is closed and the remaining connections
// (if any) are dropped.
ctx := t.Context()
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetDatabaseUsers([]string{"*"})
alice.SetDatabaseNames([]string{"*"})
alice.SetRoles([]string{"access"})
authProcess, err := testserver.NewTeleportProcess(
t.TempDir(),
testserver.WithBootstrap(connector, alice),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
cfg.InsecureMode = true
cfg.Databases.Enabled = true
cfg.Databases.Databases = []servicecfg.Database{
{
Name: "postgres-local",
Protocol: defaults.ProtocolPostgres,
URI: "external-pg:5432",
},
{
Name: "mysql-local",
Protocol: defaults.ProtocolMySQL,
URI: "external-mysql:3306",
},
}
}),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, authProcess.Close())
require.NoError(t, authProcess.Wait())
})
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := authProcess.ProxyWebAddr()
require.NoError(t, err)
// Log into Teleport cluster.
err = Run(ctx, []string{
"login", "--insecure", "--debug", "--proxy", proxyAddr.String(),
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()))
require.NoError(t, err)
benchmarkErrorLineParser := regexp.MustCompile("`host=(.+?) +user=(.+?) database=(.+?)`: (.+)$")
args := []string{
"bench", "postgres", "--insecure",
// Benchmark options to limit benchmark to a single execution.
"--rate", "1", "--duration", "1s",
}
for name, tc := range map[string]struct {
database string
additionalFlags []string
expectCommandErr bool
expectedErrContains string
expectedHost string
expectedUser string
expectedDatabase string
}{
"connect to database": {
database: "postgres-local",
additionalFlags: []string{"--db-user", "username", "--db-name", "database"},
expectedErrContains: "lookup external-pg",
// When connecting to Teleport databases, it will use a local proxy.
expectedHost: "127.0.0.1",
expectedUser: "username",
expectedDatabase: "database",
},
"direct connection": {
database: "postgres://direct_user@direct-pg:5432/direct_database",
expectedErrContains: "lookup direct-pg",
expectedHost: "direct-pg",
expectedUser: "direct_user",
expectedDatabase: "direct_database",
},
"no postgres database found": {
database: "mysql-local",
expectCommandErr: true,
},
} {
t.Run(name, func(t *testing.T) {
commandOutput := new(bytes.Buffer)
err := Run(
ctx,
append(args, append(tc.additionalFlags, tc.database)...),
setCopyStdout(commandOutput), setHomePath(tmpHomePath),
)
if tc.expectCommandErr {
require.Error(t, err)
return
}
lines := bytes.Split(commandOutput.Bytes(), []byte("\n"))
var errorLine string
for _, line := range lines {
if bytes.HasPrefix(line, []byte("* Last error:")) {
errorLine = string(line)
break
}
}
require.NotEmpty(t, errorLine, "expected benchmark to fail")
parsed := benchmarkErrorLineParser.FindStringSubmatch(errorLine)
require.Len(t, parsed, 5, "unexpecter benchmark error: %q", errorLine)
host, username, database, benchmarkError := parsed[1], parsed[2], parsed[3], parsed[4]
require.Contains(t, benchmarkError, tc.expectedErrContains)
require.Equal(t, tc.expectedHost, host)
require.Equal(t, tc.expectedUser, username)
require.Equal(t, tc.expectedDatabase, database)
})
}
}
func TestBenchmarkMySQL(t *testing.T) {
t.Parallel()
// Canceling the context right after the test ensures the local proxy
// started by the benchmark command is closed and the remaining connections
// (if any) are dropped.
ctx := t.Context()
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetDatabaseUsers([]string{"*"})
alice.SetDatabaseNames([]string{"*"})
alice.SetRoles([]string{"access"})
authProcess, err := testserver.NewTeleportProcess(
t.TempDir(),
testserver.WithBootstrap(connector, alice),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
cfg.Databases.Enabled = true
cfg.Databases.Databases = []servicecfg.Database{
{
Name: "postgres-local",
Protocol: defaults.ProtocolPostgres,
URI: "external-pg:5432",
},
{
Name: "mysql-local",
Protocol: defaults.ProtocolMySQL,
URI: "external-mysql:3306",
},
}
}),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, authProcess.Close())
require.NoError(t, authProcess.Wait())
})
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := authProcess.ProxyWebAddr()
require.NoError(t, err)
// Log into Teleport cluster.
err = Run(ctx, []string{
"login", "--insecure", "--debug", "--proxy", proxyAddr.String(),
}, setHomePath(tmpHomePath), setMockSSOLogin(authServer, alice, connector.GetName()))
require.NoError(t, err)
args := []string{
"bench", "mysql", "--insecure",
// Benchmark options to limit benchmark to a single execution.
"--rate", "1", "--duration", "1s",
}
for name, tc := range map[string]struct {
database string
additionalFlags []string
expectCommandErr bool
expectedErrContains string
}{
"connect to database": {
database: "mysql-local",
additionalFlags: []string{"--db-user", "username", "--db-name", "database"},
// Expect a MySQL driver error where the server is not working correctly.
expectedErrContains: "ERROR 1105 (HY000)",
},
"direct connection": {
database: "mysql://direct_user@test:3306/direct_database",
expectedErrContains: "lookup test",
},
"no mysql database found": {
database: "postgres-local",
expectCommandErr: true,
},
} {
t.Run(name, func(t *testing.T) {
commandOutput := new(bytes.Buffer)
err := Run(
ctx,
append(args, append(tc.additionalFlags, tc.database)...),
setCopyStdout(commandOutput), setHomePath(tmpHomePath),
)
if tc.expectCommandErr {
require.Error(t, err)
return
}
lines := bytes.Split(commandOutput.Bytes(), []byte("\n"))
var errorLine string
for _, line := range lines {
if bytes.HasPrefix(line, []byte("* Last error:")) {
errorLine = string(line)
break
}
}
require.NotEmpty(t, errorLine, "expected benchmark to fail")
require.Contains(t, errorLine, tc.expectedErrContains)
})
}
}
func TestLogout(t *testing.T) {
t.Parallel()
ctx := context.Background()
sshKey, tlsKey, err := cryptosuites.GenerateUserSSHAndTLSKey(ctx, func(_ context.Context) (types.SignatureAlgorithmSuite, error) {
return types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1, nil
})
require.NoError(t, err)
sshPriv, err := keys.NewPrivateKey(sshKey)
require.NoError(t, err)
tlsPriv, err := keys.NewPrivateKey(tlsKey)
require.NoError(t, err)
clientKeyRing := &client.KeyRing{
KeyRingIndex: client.KeyRingIndex{
ProxyHost: "proxy",
Username: "user",
ClusterName: "cluster",
},
SSHPrivateKey: sshPriv,
TLSPrivateKey: tlsPriv,
}
profile := &profile.Profile{
WebProxyAddr: clientKeyRing.ProxyHost,
Username: clientKeyRing.Username,
SiteName: clientKeyRing.ClusterName,
}
for _, tt := range []struct {
name string
modifyKeyDir func(t *testing.T, homePath string)
}{
{
name: "normal home dir",
modifyKeyDir: func(t *testing.T, homePath string) {},
},
{
name: "public key missing",
modifyKeyDir: func(t *testing.T, homePath string) {
pubKeyPath := keypaths.PublicKeyPath(homePath, clientKeyRing.ProxyHost, clientKeyRing.Username)
require.NoError(t, os.Remove(pubKeyPath))
},
},
{
name: "SSH private key missing",
modifyKeyDir: func(t *testing.T, homePath string) {
privKeyPath := keypaths.UserSSHKeyPath(homePath, clientKeyRing.ProxyHost, clientKeyRing.Username)
require.NoError(t, os.Remove(privKeyPath))
},
},
{
name: "TLS cert is present but SSH cert is missing",
modifyKeyDir: func(t *testing.T, homePath string) {
tlsCertPath := keypaths.TLSCertPath(homePath, clientKeyRing.ProxyHost, clientKeyRing.Username)
err := os.WriteFile(tlsCertPath, []byte(fixtures.TLSCACertPEM), 0o600)
require.NoError(t, err)
sshCertPath := keypaths.SSHCertPath(homePath, clientKeyRing.ProxyHost, clientKeyRing.Username, clientKeyRing.ClusterName)
_, err = os.ReadFile(sshCertPath)
require.ErrorIs(t, err, os.ErrNotExist)
},
},
{
name: "TLS private key missing",
modifyKeyDir: func(t *testing.T, homePath string) {
privKeyPath := keypaths.UserTLSKeyPath(homePath, clientKeyRing.ProxyHost, clientKeyRing.Username)
require.NoError(t, os.Remove(privKeyPath))
},
},
{
name: "public key mismatch",
modifyKeyDir: func(t *testing.T, homePath string) {
newKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
sshPub, err := ssh.NewPublicKey(newKey.Public())
require.NoError(t, err)
pubKeyPath := keypaths.PublicKeyPath(homePath, clientKeyRing.ProxyHost, clientKeyRing.Username)
err = os.WriteFile(pubKeyPath, ssh.MarshalAuthorizedKey(sshPub), 0o600)
require.NoError(t, err)
},
},
{
name: "current profile missing",
modifyKeyDir: func(t *testing.T, homePath string) {
currentProfileFilePath := keypaths.CurrentProfileFilePath(homePath)
require.NoError(t, os.Remove(currentProfileFilePath))
},
},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tmpHomePath := t.TempDir()
store := client.NewFSClientStore(tmpHomePath)
err := store.AddKeyRing(clientKeyRing)
require.NoError(t, err)
store.SaveProfile(profile, true)
tt.modifyKeyDir(t, tmpHomePath)
_, err = os.Lstat(tmpHomePath)
require.NoError(t, err)
err = Run(context.Background(), []string{"logout"}, setHomePath(tmpHomePath))
require.NoError(t, err, trace.DebugReport(err))
// directory should be empty.
f, err := os.Open(tmpHomePath)
require.NoError(t, err)
entries, err := f.ReadDir(1)
require.ErrorIs(t, err, io.EOF, "expected empty directory, but found %v", entries)
})
}
}
func Test_formatActiveDB(t *testing.T) {
t.Parallel()
tests := []struct {
name string
active tlsca.RouteToDatabase
displayName string
expect string
}{
{
name: "no route details",
active: tlsca.RouteToDatabase{
ServiceName: "my-db",
},
displayName: "my-db",
expect: "> my-db",
},
{
name: "different display name",
active: tlsca.RouteToDatabase{
ServiceName: "my-db",
},
displayName: "display-name",
expect: "> display-name",
},
{
name: "user only",
active: tlsca.RouteToDatabase{
ServiceName: "my-db",
Username: "alice",
},
displayName: "my-db",
expect: "> my-db (user: alice)",
},
{
name: "db only",
active: tlsca.RouteToDatabase{
ServiceName: "my-db",
Database: "sales",
},
displayName: "my-db",
expect: "> my-db (db: sales)",
},
{
name: "user & db",
active: tlsca.RouteToDatabase{
ServiceName: "my-db",
Username: "alice",
Database: "sales",
},
displayName: "my-db",
expect: "> my-db (user: alice, db: sales)",
},
{
name: "db & roles",
active: tlsca.RouteToDatabase{
ServiceName: "my-db",
Database: "sales",
Roles: []string{"reader", "writer"},
},
displayName: "my-db",
expect: "> my-db (db: sales, roles: [reader writer])",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require.Equal(t, test.expect, formatActiveDB(test.active, test.displayName))
})
}
}
func TestFlatten(t *testing.T) {
// Test setup: create a server and a user
home := t.TempDir()
identityPath := filepath.Join(t.TempDir(), "identity.pem")
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
connector := mockConnector(t)
authProcess, proxyProcess := makeTestServers(t, withBootstrap(connector, alice))
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
// Test setup: log in and obtain a valid identity for the user
conf := CLIConf{
Username: alice.GetName(),
Proxy: proxyAddr.String(),
InsecureSkipVerify: true,
IdentityFileOut: identityPath,
IdentityFormat: identityfile.FormatFile,
HomePath: home,
AuthConnector: connector.GetName(),
MockSSOLogin: mockSSOLogin(authServer, alice),
Context: context.Background(),
}
require.NoError(t, onLogin(&conf))
// Test setup: validate we got a valid identity
_, err = identityfile.KeyRingFromIdentityFile(identityPath, "proxy.example.com", "")
require.NoError(t, err)
// Test execution: flatten the identity previously obtained in a new home.
freshHome := t.TempDir()
conf = CLIConf{
Proxy: proxyAddr.String(),
InsecureSkipVerify: true,
IdentityFileIn: identityPath,
HomePath: freshHome,
Context: context.Background(),
}
require.NoError(t, flattenIdentity(&conf))
// Test execution: validate that the newly created profile can be used to build a valid client.
clt, err := makeClient(&conf)
require.NoError(t, err)
_, err = clt.Ping(context.Background())
require.NoError(t, err)
// Test execution: validate that flattening succeeds if a profile already exists.
conf = CLIConf{
Proxy: proxyAddr.String(),
InsecureSkipVerify: true,
IdentityFileIn: identityPath,
HomePath: freshHome,
Context: context.Background(),
}
require.NoError(t, flattenIdentity(&conf), "unexpected error when overwriting a tsh profile")
}
// TestListingResourcesAcrossClusters validates that tsh ls -R
// returns expected results for root and leaf clusters.
func TestListingResourcesAcrossClusters(t *testing.T) {
ctx := t.Context()
createAgent(t)
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.WithDebugApp(),
testserver.WithConfig(func(cfg *servicecfg.Config) {
// Enable DB
cfg.InsecureMode = true
cfg.Databases.Enabled = true
cfg.Databases.Databases = []servicecfg.Database{
{
Name: "db01",
Protocol: defaults.ProtocolPostgres,
URI: "localhost:5432",
},
}
}),
}
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(connector, accessUser),
testserver.WithHostname("node02"),
testserver.WithClusterName("leaf"),
testserver.WithDebugApp(),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.InsecureMode = true
// Enable DB
cfg.Databases.Enabled = true
cfg.Databases.Databases = []servicecfg.Database{
{
Name: "db02",
Protocol: defaults.ProtocolPostgres,
URI: "localhost:5432",
},
}
}),
}
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)
var (
rootNode, leafNode *types.ServerV2
rootDB, leafDB *types.DatabaseV3
rootApp, leafApp *types.AppV3
)
require.EventuallyWithT(t, func(t *assert.CollectT) {
rootNodes, err := rootServer.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, rootNodes, 1)
leafNodes, err := leafServer.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, leafNodes, 1)
rootDatabases, err := rootServer.GetAuthServer().GetDatabaseServers(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, rootDatabases, 1)
leafDatabases, err := leafServer.GetAuthServer().GetDatabaseServers(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, leafDatabases, 1)
rootApps, err := rootServer.GetAuthServer().GetApplicationServers(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, rootApps, 1)
leafApps, err := leafServer.GetAuthServer().GetApplicationServers(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, leafApps, 1)
rootNode = rootNodes[0].(*types.ServerV2)
leafNode = leafNodes[0].(*types.ServerV2)
rootDB = rootDatabases[0].GetDatabase().(*types.DatabaseV3)
leafDB = leafDatabases[0].GetDatabase().(*types.DatabaseV3)
rootApp = rootApps[0].GetApp().(*types.AppV3)
leafApp = leafApps[0].GetApp().(*types.AppV3)
}, 10*time.Second, 100*time.Millisecond)
t.Run("nodes", func(t *testing.T) {
t.Parallel()
testListingResources(t,
listPack[*types.ServerV2]{
rootProcess: rootServer,
leafProcess: leafServer,
rootResource: rootNode,
leafResource: leafNode,
user: accessUser,
connectorName: connector.GetName(),
args: []string{"ls"},
},
func(t *testing.T, proxyAddr string, recursive bool, raw []byte) []*types.ServerV2 {
type recursiveServer struct {
Proxy string `json:"proxy"`
Cluster string `json:"cluster"`
Node *types.ServerV2 `json:"node"`
}
var nodes []*types.ServerV2
if recursive {
var servers []recursiveServer
require.NoError(t, json.Unmarshal(raw, &servers))
require.NotEmpty(t, servers)
nodes = make([]*types.ServerV2, 0, len(servers))
for _, s := range servers {
if s.Node.GetHostname() == "node01" {
assert.Equal(t, "root", s.Cluster)
} else {
assert.Equal(t, "leaf", s.Cluster)
}
assert.Equal(t, proxyAddr, s.Proxy)
require.NoError(t, s.Node.CheckAndSetDefaults())
nodes = append(nodes, s.Node)
}
} else {
err := json.Unmarshal(raw, &nodes)
assert.NoError(t, err)
require.NotEmpty(t, nodes)
for _, s := range nodes {
require.NoError(t, s.CheckAndSetDefaults())
}
}
return nodes
},
func(a, b *types.ServerV2) bool {
return strings.Compare(a.GetName(), b.GetName()) < 0
},
)
})
t.Run("databases", func(t *testing.T) {
t.Parallel()
testListingResources(t,
listPack[*types.DatabaseV3]{
rootProcess: rootServer,
leafProcess: leafServer,
rootResource: rootDB,
leafResource: leafDB,
user: accessUser,
connectorName: connector.GetName(),
args: []string{"db", "ls"},
},
func(t *testing.T, proxyAddr string, recursive bool, raw []byte) []*types.DatabaseV3 {
type recursiveServer struct {
Proxy string `json:"proxy"`
Cluster string `json:"cluster"`
Database *types.DatabaseV3 `json:"database"`
}
var databases []*types.DatabaseV3
if recursive {
var servers []recursiveServer
assert.NoError(t, json.Unmarshal(raw, &servers))
require.NotEmpty(t, servers)
databases = make([]*types.DatabaseV3, 0, len(servers))
for _, s := range servers {
if s.Database.GetName() == "db01" {
assert.Equal(t, "root", s.Cluster)
} else {
assert.Equal(t, "leaf", s.Cluster)
}
assert.Equal(t, proxyAddr, s.Proxy)
require.NoError(t, s.Database.CheckAndSetDefaults())
databases = append(databases, s.Database)
}
} else {
var servers []*types.DatabaseV3
assert.NoError(t, json.Unmarshal(raw, &servers))
require.NotEmpty(t, servers)
for _, s := range servers {
require.NoError(t, s.CheckAndSetDefaults())
databases = append(databases, s)
}
}
return databases
},
func(a, b *types.DatabaseV3) bool {
return strings.Compare(a.GetName(), b.GetName()) < 0
},
)
})
t.Run("applications", func(t *testing.T) {
t.Parallel()
testListingResources(t,
listPack[*types.AppV3]{
rootProcess: rootServer,
leafProcess: leafServer,
rootResource: rootApp,
leafResource: leafApp,
user: accessUser,
connectorName: connector.GetName(),
args: []string{"apps", "ls"},
},
func(t *testing.T, proxyAddr string, recursive bool, raw []byte) []*types.AppV3 {
type recursiveServer struct {
Proxy string `json:"proxy"`
Cluster string `json:"cluster"`
App *types.AppV3 `json:"app"`
}
var apps []*types.AppV3
if recursive {
var servers []recursiveServer
assert.NoError(t, json.Unmarshal(raw, &servers))
require.NotEmpty(t, servers)
apps = make([]*types.AppV3, 0, len(servers))
for _, s := range servers {
if s.App.GetPublicAddr() == "dumper.root" {
assert.Equal(t, "root", s.Cluster)
} else {
assert.Equal(t, "leaf", s.Cluster)
}
assert.Equal(t, proxyAddr, s.Proxy)
require.NoError(t, s.App.CheckAndSetDefaults())
apps = append(apps, s.App)
}
} else {
assert.NoError(t, json.Unmarshal(raw, &apps))
require.NotEmpty(t, apps)
for _, s := range apps {
require.NoError(t, s.CheckAndSetDefaults())
}
}
return apps
},
func(a, b *types.AppV3) bool {
return strings.Compare(a.GetPublicAddr(), b.GetPublicAddr()) < 0
},
)
})
}
type listPack[T any] struct {
rootProcess *service.TeleportProcess
leafProcess *service.TeleportProcess
rootResource T
leafResource T
user types.User
connectorName string
args []string
}
func testListingResources[T any](t *testing.T, pack listPack[T], unmarshalFunc func(*testing.T, string, bool, []byte) []T, lessFunc func(a, b T) bool) {
ctx := context.Background()
rootProxyAddr, err := pack.rootProcess.ProxyWebAddr()
require.NoError(t, err)
leafProxyAddr, err := pack.leafProcess.ProxyWebAddr()
require.NoError(t, err)
tests := []struct {
name string
proxyAddr *utils.NetAddr
cluster string
auth *auth.Server
recursive bool
expected []T
}{
{
name: "root",
proxyAddr: rootProxyAddr,
cluster: "root",
auth: pack.rootProcess.GetAuthServer(),
expected: []T{pack.rootResource},
},
{
name: "leaf",
proxyAddr: leafProxyAddr,
cluster: "leaf",
auth: pack.leafProcess.GetAuthServer(),
expected: []T{pack.leafResource},
},
{
name: "leaf via root",
proxyAddr: rootProxyAddr,
cluster: "leaf",
auth: pack.rootProcess.GetAuthServer(),
expected: []T{pack.leafResource},
},
{
name: "root recursive",
proxyAddr: rootProxyAddr,
cluster: "root",
auth: pack.rootProcess.GetAuthServer(),
recursive: true,
expected: []T{pack.rootResource, pack.leafResource},
},
{
name: "leaf recursive",
proxyAddr: leafProxyAddr,
cluster: "leaf",
auth: pack.leafProcess.GetAuthServer(),
recursive: true,
expected: []T{pack.leafResource},
},
{
name: "leaf via root recursive",
proxyAddr: rootProxyAddr,
cluster: "leaf",
auth: pack.rootProcess.GetAuthServer(),
recursive: true,
expected: []T{pack.leafResource},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
tmpHomePath := t.TempDir()
err = Run(
ctx,
[]string{
"--insecure",
"login",
"--proxy", test.proxyAddr.String(),
test.cluster,
},
setHomePath(tmpHomePath),
setMockSSOLogin(test.auth, pack.user, pack.connectorName),
)
require.NoError(t, err)
stdout := &output{buf: bytes.Buffer{}}
args := slices.Clone(pack.args)
args = append(args, "--format", "json")
if test.recursive {
args = append(args, "-R")
}
err = Run(ctx,
args,
setHomePath(tmpHomePath),
func(conf *CLIConf) error {
conf.overrideStdin = &bytes.Buffer{}
conf.OverrideStdout = stdout
return nil
},
)
require.NoError(t, err)
out := unmarshalFunc(t, test.proxyAddr.String(), test.recursive, stdout.buf.Bytes())
require.Empty(t, cmp.Diff(test.expected, out, cmpopts.SortSlices(lessFunc)))
})
}
}
func TestStatusPrintsProfilesIfNoActiveProfile(t *testing.T) {
t.Parallel()
buf := bytes.NewBuffer([]byte{})
err := Run(context.Background(), []string{
"status",
}, setHomePath(t.TempDir()), func(c *CLIConf) error {
c.OverrideStdout = buf
profile := &profile.Profile{
WebProxyAddr: "proxy:3080",
Username: "testuser",
}
// setCurrent is false, so there is no active profile.
err := c.getClientStore().SaveProfile(profile, false)
require.NoError(t, err)
return nil
})
require.Contains(t, buf.String(),
` Profile URL: https://proxy:3080
Logged in as: testuser
Cluster: proxy`)
require.True(t, trace.IsNotFound(err))
require.ErrorContains(t, err, "No active profile.")
}
// TestProxyTemplates verifies proxy templates apply properly to client config.
func TestProxyTemplatesMakeClient(t *testing.T) {
t.Parallel()
tshConfig := client.TSHConfig{
ProxyTemplates: client.ProxyTemplates{
{
Template: `^(.+)\.(us.example.com):(.+)$`,
Proxy: "$2:443",
Cluster: "$2",
Host: "$1:4022",
},
},
}
require.NoError(t, tshConfig.Check())
newCLIConf := func(modify func(conf *CLIConf)) *CLIConf {
// minimal configuration (with defaults)
conf := &CLIConf{
Proxy: "proxy:3080",
UserHost: "localhost",
HomePath: t.TempDir(),
TSHConfig: tshConfig,
}
// Create a empty profile so we don't ping proxy.
profile := &profile.Profile{
SSHProxyAddr: "proxy:3023",
WebProxyAddr: "proxy:3080",
}
err := conf.getClientStore().SaveProfile(profile, true)
require.NoError(t, err)
modify(conf)
return conf
}
for _, tt := range []struct {
name string
InConf *CLIConf
expectErr bool
outHost string
outPort int
outCluster string
outJumpHosts []utils.JumpHost
}{
{
name: "does not match template",
InConf: newCLIConf(func(conf *CLIConf) {
conf.UserHost = "node-1.cn.example.com:3022"
}),
outHost: "node-1.cn.example.com:3022",
outCluster: "proxy",
},
{
name: "does not match template with -J {{proxy}}",
InConf: newCLIConf(func(conf *CLIConf) {
conf.UserHost = "node-1.cn.example.com:3022"
conf.ProxyJump = "{{proxy}}"
}),
expectErr: true,
},
{
name: "match with full host set",
InConf: newCLIConf(func(conf *CLIConf) {
conf.UserHost = "[email protected]:3022"
}),
outHost: "node-1",
outPort: 4022,
outCluster: "us.example.com",
outJumpHosts: []utils.JumpHost{{
Addr: utils.NetAddr{
Addr: "us.example.com:443",
AddrNetwork: "tcp",
},
}},
},
{
name: "match with host and port set",
InConf: newCLIConf(func(conf *CLIConf) {
conf.UserHost = "[email protected]"
conf.NodePort = 3022
}),
outHost: "node-1",
outPort: 4022,
outCluster: "us.example.com",
outJumpHosts: []utils.JumpHost{{
Addr: utils.NetAddr{
Addr: "us.example.com:443",
AddrNetwork: "tcp",
},
}},
},
{
name: "match with -J {{proxy}} set",
InConf: newCLIConf(func(conf *CLIConf) {
conf.UserHost = "node-1.us.example.com:3022"
conf.ProxyJump = "{{proxy}}"
}),
outHost: "node-1",
outPort: 4022,
outCluster: "us.example.com",
outJumpHosts: []utils.JumpHost{{
Addr: utils.NetAddr{
Addr: "us.example.com:443",
AddrNetwork: "tcp",
},
}},
},
{
name: "match does not overwrite user specified proxy jump",
InConf: newCLIConf(func(conf *CLIConf) {
conf.UserHost = "node-1.us.example.com:3022"
conf.SiteName = "specified.cluster"
conf.ProxyJump = "specified.proxy.com:443"
}),
outHost: "node-1",
outPort: 4022,
outCluster: "us.example.com",
outJumpHosts: []utils.JumpHost{{
Addr: utils.NetAddr{
Addr: "specified.proxy.com:443",
AddrNetwork: "tcp",
},
}},
},
} {
t.Run(tt.name, func(t *testing.T) {
tc, err := makeClient(tt.InConf)
if tt.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.outHost, tc.Host)
require.Equal(t, tt.outPort, tc.HostPort)
require.Equal(t, tt.outJumpHosts, tc.JumpHosts)
require.Equal(t, tt.outCluster, tc.SiteName)
})
}
}
func TestRolesToString(t *testing.T) {
tests := []struct {
name string
roles []string
expected string
debug bool
}{
{
name: "empty",
roles: []string{},
expected: "",
},
{
name: "exceed threshold okta roles should be squashed",
roles: append([]string{"app-figma-reviewer-okta-acl-role", "app-figma-access-okta-acl-role"}, []string{"r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", "r10"}...),
expected: "r1, r10, r2, r3, r4, r5, r6, r7, r8, r9, and 2 more Okta access list roles ...",
},
{
name: "debug flag",
roles: append([]string{"app-figma-reviewer-okta-acl-role", "app-figma-access-okta-acl-role"}, []string{"r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", "r10"}...),
debug: true,
expected: "r1, r10, r2, r3, r4, r5, r6, r7, r8, r9, app-figma-access-okta-acl-role, app-figma-reviewer-okta-acl-role",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expected, rolesToString(tc.debug, tc.roles))
})
}
}
// TestResolve tests that host resolution works for various inputs and
// that proxy templates are respected.
func TestResolve(t *testing.T) {
modulestest.SetTestModules(t, modulestest.Modules{TestBuildType: modules.BuildEnterprise})
ctx := t.Context()
accessRoleName := "access"
sshHostname := "test-ssh-server"
accessUser, err := types.NewUser(accessRoleName)
require.NoError(t, err)
accessUser.SetRoles([]string{accessRoleName})
user, err := user.Current()
require.NoError(t, err)
accessUser.SetLogins([]string{user.Username})
traits := map[string][]string{
constants.TraitLogins: {user.Username},
}
accessUser.SetTraits(traits)
connector := mockConnector(t)
rootServerOpts := []testserver.TestServerOptFunc{
testserver.WithBootstrap(connector, accessUser),
testserver.WithHostname(sshHostname),
testserver.WithClusterName("root"),
testserver.WithSSHPublicAddrs("127.0.0.1:0"),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.SSH.Enabled = true
cfg.SSH.PublicAddrs = []utils.NetAddr{cfg.SSH.Addr}
cfg.SSH.DisableCreateHostUser = true
cfg.SSH.Labels = map[string]string{
"animal": "llama",
"env": "dev",
}
}),
}
rootServer, err := testserver.NewTeleportProcess(t.TempDir(), rootServerOpts...)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, rootServer.Close())
require.NoError(t, rootServer.Wait())
})
node, err := testserver.NewTeleportProcess(t.TempDir(),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.SetAuthServerAddresses(rootServer.Config.AuthServerAddresses())
cfg.Hostname = "second-node"
cfg.Auth.Enabled = false
cfg.Proxy.Enabled = false
cfg.SSH.Enabled = true
cfg.SSH.DisableCreateHostUser = true
cfg.SSH.Labels = map[string]string{
"animal": "shark",
"env": "dev",
}
}))
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, node.Close())
require.NoError(t, node.Wait())
})
rootProxyAddr, err := rootServer.ProxyWebAddr()
require.NoError(t, err)
require.EventuallyWithT(t, func(t *assert.CollectT) {
found, err := rootServer.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, found, 2)
}, 10*time.Second, 100*time.Millisecond)
tmpHomePath := t.TempDir()
rootAuth := rootServer.GetAuthServer()
err = Run(ctx, []string{
"login",
"--insecure",
"--proxy", rootProxyAddr.String(),
"--user", user.Username,
}, setHomePath(tmpHomePath), setMockSSOLogin(rootAuth, accessUser, connector.GetName()))
require.NoError(t, err)
tests := []struct {
name string
hostname string
quiet bool
assertion require.ErrorAssertionFunc
}{
{
name: "resolved without using templates",
hostname: sshHostname,
assertion: require.NoError,
},
{
name: "resolved via predicate from template",
hostname: "2.3.4.5",
assertion: require.NoError,
},
{
name: "resolved via search from template",
hostname: "llama.example.com:3023",
assertion: require.NoError,
},
{
name: "no matching host",
hostname: "asdf",
assertion: func(tt require.TestingT, err error, i ...any) {
require.Error(tt, err, i...)
require.ErrorContains(tt, err, "no matching hosts", i...)
},
},
{
name: "quiet prevents output",
hostname: node.Config.Hostname,
quiet: true,
assertion: require.NoError,
},
{
name: "multiple matching hosts",
hostname: "dev.example.com",
assertion: func(tt require.TestingT, err error, i ...any) {
require.Error(tt, err, i...)
require.ErrorContains(tt, err, "multiple matching hosts", i...)
},
},
}
for _, test := range tests {
ctx := context.Background()
t.Run(test.name, func(t *testing.T) {
t.Parallel()
stdout := &output{buf: bytes.Buffer{}}
stderr := &output{buf: bytes.Buffer{}}
args := []string{"resolve"}
if test.quiet {
args = append(args, "-q")
}
args = append(args, test.hostname)
err := Run(ctx, args,
setHomePath(tmpHomePath),
setTSHConfig(client.TSHConfig{
ProxyTemplates: client.ProxyTemplates{
{
Template: `^([0-9\.]+):\d+$`,
Query: `labels["animal"] == "llama"`,
},
{
Template: `^(.*).example.com:\d+$`,
Search: "$1",
},
},
}),
func(conf *CLIConf) error {
conf.overrideStdin = &bytes.Buffer{}
conf.OverrideStdout = stdout
conf.overrideStderr = stderr
return nil
},
)
test.assertion(t, err)
if err != nil {
return
}
if test.quiet {
require.Empty(t, stdout.String())
} else {
require.Contains(t, stdout.String(), sshHostname)
}
})
}
}
// TestInteractiveCompatibilityFlags verifies that "tsh ssh -t" and "tsh ssh -T"
// behave similar to OpenSSH.
func TestInteractiveCompatibilityFlags(t *testing.T) {
// Require the "tty" program exist somewhere in $PATH, otherwise fail.
tty, err := exec.LookPath("tty")
require.NoError(t, err)
// Create roles and users that will be used in test.
local, err := user.Current()
require.NoError(t, err)
nodeAccess, err := types.NewRole("foo", types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{local.Username},
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
require.NoError(t, err)
user, err := types.NewUser("[email protected]")
require.NoError(t, err)
user.SetRoles([]string{"access", "foo"})
// Create Auth, Proxy, and Node Service used in tests.
hostname := uuid.NewString()
connector := mockConnector(t)
authProcess, proxyProcess := makeTestServers(t,
withBootstrap(connector, nodeAccess, user),
withConfig(func(cfg *servicecfg.Config) {
cfg.Hostname = hostname
cfg.SSH.Enabled = true
cfg.SSH.Addr = utils.NetAddr{
AddrNetwork: "tcp",
Addr: net.JoinHostPort("127.0.0.1", ports.Pop()),
}
}))
// Extract Auth Service and Proxy Service address.
authServer := authProcess.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := proxyProcess.ProxyWebAddr()
require.NoError(t, err)
// Run "tsh login".
home := t.TempDir()
err = Run(context.Background(), []string{
"login",
"--insecure",
"--debug",
"--proxy", proxyAddr.String()},
setHomePath(home),
setMockSSOLogin(authServer, user, connector.GetName()))
require.NoError(t, err)
// Test compatibility with OpenSSH "-T" and "-t" flags. Note that multiple
// -t options is still not supported.
//
// From "man 1 ssh".
//
// -T Disable pseudo-terminal allocation.
// -t Force pseudo-terminal allocation. This can be used to execute
// arbitrary screen-based programs on a remote machine, which can
// be very useful, e.g. when implementing menu services. Multiple
// -t options force tty allocation, even if ssh has no local tty.
tests := []struct {
name string
flag string
assertError require.ErrorAssertionFunc
}{
{
name: "disable pseudo-terminal allocation",
flag: "-T",
assertError: func(t require.TestingT, err error, i ...any) {
var exiterr *exec.ExitError
if errors.As(err, &exiterr) {
require.Equal(t, 1, exiterr.ExitCode())
} else {
require.Fail(t, "Non *exec.ExitError type: %T.", err)
}
},
},
{
name: "force pseudo-terminal allocation",
flag: "-t",
assertError: require.NoError,
},
}
// Turn the binary running tests into a fake "tsh" binary so it can
// re-execute itself.
t.Setenv(types.HomeEnvVar, home)
t.Setenv(tshBinMainTestEnv, "1")
tshBin, err := os.Executable()
require.NoError(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Add some resiliency for cases where connections are attempted
// prior to the inventory being updated to contain the target node
// above.
require.EventuallyWithT(t, func(t *assert.CollectT) {
out, err := exec.Command(tshBin, "ssh", tt.flag, hostname, tty).CombinedOutput()
tt.assertError(t, err, string(out))
}, 20*time.Second, 100*time.Millisecond)
})
}
}
// TestVersionCompatibilityFlags verifies that "tsh ssh -V" returns Teleport
// version similar to OpenSSH.
func TestVersionCompatibilityFlags(t *testing.T) {
t.Setenv(tshBinMainTestEnv, "1")
tshBin, err := os.Executable()
require.NoError(t, err)
output, err := exec.Command(tshBin, "ssh", "-V").CombinedOutput()
require.NoError(t, err, output)
require.Equal(t, "Teleport CLI", string(bytes.TrimSpace(output)))
}
// TestSCP validates that tsh scp correctly copy file content while also
// ensuring that proxy templates are respected.
func TestSCP(t *testing.T) {
modulestest.SetTestModules(t, modulestest.Modules{TestBuildType: modules.BuildEnterprise})
ctx := t.Context()
accessRoleName := "access"
sshHostname := "test-ssh-server"
accessUser, err := types.NewUser(accessRoleName)
require.NoError(t, err)
accessUser.SetRoles([]string{accessRoleName})
user, err := user.Current()
require.NoError(t, err)
accessUser.SetLogins([]string{user.Username})
traits := map[string][]string{
constants.TraitLogins: {user.Username},
}
accessUser.SetTraits(traits)
connector := mockConnector(t)
rootServerOpts := []testserver.TestServerOptFunc{
testserver.WithBootstrap(connector, accessUser),
testserver.WithHostname(sshHostname),
testserver.WithClusterName("root"),
testserver.WithSSHPublicAddrs("127.0.0.1:0"),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.SSH.Enabled = true
cfg.SSH.PublicAddrs = []utils.NetAddr{cfg.SSH.Addr}
cfg.SSH.DisableCreateHostUser = true
cfg.SSH.Labels = map[string]string{
"animal": "llama",
"env": "dev",
}
}),
}
rootServer, err := testserver.NewTeleportProcess(t.TempDir(), rootServerOpts...)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, rootServer.Close())
require.NoError(t, rootServer.Wait())
})
// Create a second server to test ambiguous matching.
const secondServerHostname = "second-node"
server, err := testserver.NewTeleportProcess(t.TempDir(),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.SetAuthServerAddresses(rootServer.Config.AuthServerAddresses())
cfg.Hostname = secondServerHostname
cfg.Auth.Enabled = false
cfg.Proxy.Enabled = false
cfg.SSH.Enabled = true
cfg.SSH.DisableCreateHostUser = true
cfg.SSH.Labels = map[string]string{
"animal": "shark",
"env": "dev",
}
}))
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, server.Close())
require.NoError(t, server.Wait())
})
rootProxyAddr, err := rootServer.ProxyWebAddr()
require.NoError(t, err)
require.EventuallyWithT(t, func(t *assert.CollectT) {
found, err := rootServer.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, found, 2)
}, 10*time.Second, 100*time.Millisecond)
tmpHomePath := t.TempDir()
rootAuth := rootServer.GetAuthServer()
err = Run(ctx, []string{
"login",
"--insecure",
"--proxy", rootProxyAddr.String(),
"--user", user.Username,
}, setHomePath(tmpHomePath), setMockSSOLogin(rootAuth, accessUser, connector.GetName()))
require.NoError(t, err)
sourceFile1 := filepath.Join(t.TempDir(), "source-file")
expectedFile1 := []byte{6, 7, 8, 9, 0}
require.NoError(t, os.WriteFile(sourceFile1, expectedFile1, 0o644))
sourceFile2 := filepath.Join(t.TempDir(), "source-file2")
expectedFile2 := []byte{1, 2, 3, 4, 5}
require.NoError(t, os.WriteFile(sourceFile2, expectedFile2, 0o644))
targetFile1 := uuid.NewString()
createFile := func(t *testing.T, dir, file string) {
t.Helper()
f, err := os.CreateTemp(dir, file)
require.NoError(t, err)
require.NoError(t, f.Close())
}
tests := []struct {
name string
source []string
destination func(t *testing.T, dir string) string
assertion require.ErrorAssertionFunc
expected map[string][]byte
}{
{
name: "no paths provided",
destination: func(*testing.T, string) string { return "" },
assertion: func(tt require.TestingT, err error, i ...any) {
require.Error(tt, err, i...)
require.ErrorContains(tt, err, "local and remote destinations are required", i...)
},
},
{
name: "source resolved without using templates",
source: []string{sshHostname + ":" + sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return filepath.Join(dir, targetFile1)
},
assertion: require.NoError,
expected: map[string][]byte{
targetFile1: expectedFile1,
},
},
{
name: "source resolved via predicate from template",
source: []string{"2.3.4.5:" + sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return filepath.Join(dir, targetFile1)
},
assertion: require.NoError,
expected: map[string][]byte{
targetFile1: expectedFile1,
},
},
{
name: "source resolved via search from template",
source: []string{"llama.example.com:" + sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return filepath.Join(dir, targetFile1)
},
assertion: require.NoError,
expected: map[string][]byte{
targetFile1: expectedFile1,
},
},
{
name: "source no matching host",
source: []string{"asdf.example.com:" + sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return filepath.Join(dir, targetFile1)
},
assertion: func(tt require.TestingT, err error, i ...any) {
require.Error(tt, err, i...)
require.ErrorContains(tt, err, "no matching hosts", i...)
},
},
{
name: "source multiple matching hosts",
source: []string{"dev.example.com:" + sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return filepath.Join(dir, targetFile1)
},
assertion: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(tt, err, teleport.ErrNodeIsAmbiguous, i...)
},
},
{
name: "destination resolved without using templates",
source: []string{sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return sshHostname + ":" + filepath.Join(dir, targetFile1)
},
assertion: require.NoError,
expected: map[string][]byte{
targetFile1: expectedFile1,
},
},
{
name: "destination resolved via predicate from template",
source: []string{sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return "2.3.4.5:" + filepath.Join(dir, targetFile1)
},
assertion: require.NoError,
expected: map[string][]byte{
targetFile1: expectedFile1,
},
},
{
name: "destination resolved via search from template",
source: []string{sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return "llama.example.com:" + filepath.Join(dir, targetFile1)
},
assertion: require.NoError,
expected: map[string][]byte{
targetFile1: expectedFile1,
},
},
{
name: "destination no matching host",
source: []string{sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return "asdf.example.com:" + filepath.Join(dir, targetFile1)
},
assertion: func(tt require.TestingT, err error, i ...any) {
require.Error(tt, err, i...)
require.ErrorContains(tt, err, "no matching hosts", i...)
},
},
{
name: "destination multiple matching hosts",
source: []string{sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return "dev.example.com:" + filepath.Join(dir, targetFile1)
},
assertion: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(tt, err, teleport.ErrNodeIsAmbiguous, i...)
},
},
{
name: "upload multiple files",
source: []string{sourceFile1, sourceFile2},
destination: func(t *testing.T, dir string) string { return "llama.example.com:" + dir },
assertion: require.NoError,
expected: map[string][]byte{
filepath.Base(sourceFile1): expectedFile1,
filepath.Base(sourceFile2): expectedFile2,
},
},
{
name: "two hosts without templates",
source: []string{sshHostname + ":" + sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return secondServerHostname + ":" + filepath.Join(dir, targetFile1)
},
assertion: require.NoError,
expected: map[string][]byte{
targetFile1: expectedFile1,
},
},
{
name: "two hosts with templates",
source: []string{"shark.example.com:" + sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return "2.3.4.5:" + filepath.Join(dir, targetFile1)
},
assertion: require.NoError,
expected: map[string][]byte{
targetFile1: expectedFile1,
},
},
{
name: "two hosts, one no matching host",
source: []string{"2.3.4.5:" + sourceFile1},
destination: func(t *testing.T, dir string) string {
createFile(t, dir, targetFile1)
return "asdf.example.com:" + filepath.Join(dir, targetFile1)
},
assertion: func(tt require.TestingT, err error, i ...any) {
require.Error(tt, err, i...)
require.ErrorContains(tt, err, "no matching hosts", i...)
},
},
}
for _, test := range tests {
ctx := context.Background()
t.Run(test.name, func(t *testing.T) {
t.Parallel()
outputDir := t.TempDir()
destination := test.destination(t, outputDir)
args := []string{"scp", "-d", "--no-resume", "--insecure", "-q"}
args = append(args, test.source...)
args = append(args, destination)
err := Run(ctx,
args,
setHomePath(tmpHomePath),
setTSHConfig(client.TSHConfig{
ProxyTemplates: client.ProxyTemplates{
{
Template: `^([0-9\.]+):\d+$`,
Query: `labels["animal"] == "llama"`,
},
{
Template: `^(.*).example.com:\d+$`,
Search: "$1",
},
},
}),
func(conf *CLIConf) error {
// Relogin is disabled since some of the error cases return a
// BadParameter error which triggers the re-authentication flow
// and may result in a different authentication related error
// being returned instead of the expected errors.
conf.Relogin = false
return nil
},
)
test.assertion(t, err)
if err != nil {
return
}
for file, expected := range test.expected {
got, err := os.ReadFile(filepath.Join(outputDir, file))
require.NoError(t, err)
require.Equal(t, expected, got)
}
})
}
}
func TestSetEnvVariables(t *testing.T) {
testCases := []struct {
name string
envVars map[string]string
sendEnvVariables []string
expectedExtraEnvs map[string]string
}{
{
name: "Skip unset var",
envVars: map[string]string{
"TEST_VAR1": "value1",
"TEST_VAR2": "value2",
},
sendEnvVariables: []string{"TEST_VAR1", "TEST_VAR2", "UNSET_VAR"},
expectedExtraEnvs: map[string]string{
"TEST_VAR1": "value1",
"TEST_VAR2": "value2",
},
},
{
name: "Sending empty var",
envVars: map[string]string{"EMPTY_VAR": ""},
sendEnvVariables: []string{"EMPTY_VAR"},
expectedExtraEnvs: map[string]string{"EMPTY_VAR": ""},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
for k, v := range tc.envVars {
t.Setenv(k, v)
}
c := &client.Config{}
options := Options{SendEnvVariables: tc.sendEnvVariables}
setEnvVariables(c, options)
require.Equal(t, tc.expectedExtraEnvs, c.ExtraEnvs)
})
}
}
func TestLoggingOpts(t *testing.T) {
tmpHomePath := t.TempDir()
tests := []struct {
name string
envDebug *bool
envOSLog *bool
flagDebug *bool
flagOSLog *bool
wantDebug require.BoolAssertionFunc
wantOSLog require.BoolAssertionFunc
}{
{
name: "no flags or env vars",
wantDebug: require.False,
wantOSLog: require.False,
},
{
name: "just debug flag",
flagDebug: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.False,
},
{
name: "just os-log flag",
flagOSLog: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.True,
},
{
name: "debug and os-log flags",
flagDebug: boolPtr(true),
flagOSLog: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.True,
},
{
name: "just debug env var",
envDebug: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.False,
},
{
name: "just os-log env var",
envOSLog: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.True,
},
{
name: "debug and os-log flags",
envDebug: boolPtr(true),
envOSLog: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.True,
},
{
name: "everything on",
envDebug: boolPtr(true),
envOSLog: boolPtr(true),
flagDebug: boolPtr(true),
flagOSLog: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.True,
},
{
name: "everything explicitly off",
envDebug: boolPtr(false),
envOSLog: boolPtr(false),
flagDebug: boolPtr(false),
flagOSLog: boolPtr(false),
wantDebug: require.False,
wantOSLog: require.False,
},
{
name: "debug enabled by env, disabled by flag",
envDebug: boolPtr(true),
flagDebug: boolPtr(false),
wantDebug: require.False,
wantOSLog: require.False,
},
{
name: "debug disabled by env, enabled by flag",
envDebug: boolPtr(false),
flagDebug: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.False,
},
{
name: "os-log enabled by env, disabled by flag",
envOSLog: boolPtr(true),
flagOSLog: boolPtr(false),
wantDebug: require.False,
wantOSLog: require.False,
},
{
name: "os-log disabled by env, enabled by flag",
envOSLog: boolPtr(false),
flagOSLog: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.True,
},
// Verify that final values are set after consulting both env and argv, e.g., disabling os-log
// by flag doesn't disable debug set by env.
{
name: "os-log enabled by env, disabled by flag; debug enabled by env",
envOSLog: boolPtr(true),
flagOSLog: boolPtr(false),
envDebug: boolPtr(true),
wantDebug: require.True,
wantOSLog: require.False,
},
{
name: "os-log disabled by env, enabled by flag; debug disabled by flag",
envOSLog: boolPtr(false),
flagOSLog: boolPtr(true),
flagDebug: boolPtr(false),
wantDebug: require.True,
wantOSLog: require.True,
},
// Debug is always enabled together with os-log.
{
name: "os-log enabled by flag, debug disabled by env",
flagOSLog: boolPtr(true),
envDebug: boolPtr(false),
wantDebug: require.True,
wantOSLog: require.True,
},
{
name: "os-log enabled by flag, debug disabled by flag",
flagOSLog: boolPtr(true),
flagDebug: boolPtr(false),
wantDebug: require.True,
wantOSLog: require.True,
},
{
name: "os-log enabled by env, debug disabled by env",
envOSLog: boolPtr(true),
envDebug: boolPtr(false),
wantDebug: require.True,
wantOSLog: require.True,
},
{
name: "os-log enabled by env, debug disabled by flag",
envOSLog: boolPtr(true),
flagDebug: boolPtr(false),
wantDebug: require.True,
wantOSLog: require.True,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envDebug != nil {
t.Setenv(debugEnvVar, strconv.FormatBool(*tt.envDebug))
}
if tt.envOSLog != nil {
t.Setenv(osLogEnvVar, strconv.FormatBool(*tt.envOSLog))
}
var flags []string
if tt.flagDebug != nil {
if *tt.flagDebug {
flags = append(flags, "--debug")
} else {
flags = append(flags, "--no-debug")
}
}
if tt.flagOSLog != nil {
if *tt.flagOSLog {
flags = append(flags, "--os-log")
} else {
flags = append(flags, "--no-os-log")
}
}
mustReadLoggingOpts, setLoggingOptsFromCLIConf := prepareCLIOptionForReadingLoggingOpts()
err := Run(t.Context(), append([]string{"version"}, flags...),
setHomePath(tmpHomePath), setLoggingOptsFromCLIConf)
require.NoError(t, err)
opts := mustReadLoggingOpts(t)
tt.wantDebug(t, opts.debug, "unexpected cf.Debug value")
tt.wantOSLog(t, opts.osLog, "unexpected cf.OSLog value")
})
}
}
func boolPtr(b bool) *bool {
return &b
}
func prepareCLIOptionForReadingLoggingOpts() (func(t *testing.T) loggingOpts, CliOption) {
var cf *CLIConf
setLoggingOptsFromCLIConf := func(cliConfFromRun *CLIConf) error {
cf = cliConfFromRun
return nil
}
mustReadLoggingOpts := func(t *testing.T) loggingOpts {
t.Helper()
if cf == nil {
t.Fatalf("mustReadLoggingOpts was called before setLoggingOptsFromCLIConf was executed")
}
return loggingOpts{
debug: cf.Debug,
osLog: cf.OSLog,
}
}
return mustReadLoggingOpts, setLoggingOptsFromCLIConf
}
func Test_validateKingpinAppHelp(t *testing.T) {
err := Run(
context.Background(),
[]string{"version"},
setHomePath(t.TempDir()),
func(cf *CLIConf) error {
require.NotNil(t, cf.kingpinApp)
issues := testutils.FindKingpinAppHelpIssues(cf.kingpinApp)
if len(issues) > 0 {
t.Log("Found", len(issues), "issues")
for _, issue := range issues {
t.Log(issue)
}
}
require.Empty(t, issues)
return trace.BadParameter("no need to continue")
},
)
require.ErrorIs(t, err, trace.BadParameter("no need to continue"))
}
func TestSSHForkAfterAuthentication(t *testing.T) {
u, err := user.Current()
require.NoError(t, err)
// Create resources.
accessRole, err := types.NewRole("node-access", types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{
types.Wildcard: []string{types.Wildcard},
},
Logins: []string{u.Username},
},
})
require.NoError(t, err)
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{accessRole.GetName()})
tsrv, err := testserver.NewTeleportProcess(t.TempDir(),
testserver.WithSSHLabel("foo", "bar"),
testserver.WithBootstrap(connector, accessRole, alice),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, tsrv.Close())
require.NoError(t, tsrv.Wait())
})
t.Cleanup(func() { require.NoError(t, tsrv.Close()) })
// We don't need a real second node for multi-node exec, tsh ssh should fail before that.
fakeNode, err := types.NewNode("fake", types.SubKindTeleportNode, types.ServerSpecV2{}, map[string]string{"foo": "bar"})
require.NoError(t, err)
_, err = tsrv.GetAuthServer().UpsertNode(t.Context(), fakeNode)
require.NoError(t, err)
// Use env var instead of homedir mock to preserve across the re-exec.
tmpHomeDir := filepath.Join(t.TempDir(), ".tsh")
t.Setenv(types.HomeEnvVar, tmpHomeDir)
proxyAddr, err := tsrv.ProxyWebAddr()
require.NoError(t, err)
err = Run(t.Context(), []string{
"login",
"--insecure",
"--proxy", proxyAddr.Addr,
}, setMockSSOLogin(tsrv.GetAuthServer(), alice, connector.GetName()))
require.NoError(t, err)
tests := []struct {
name string
target string
command []string
assertRun assert.ErrorAssertionFunc
assertCommandEffect func(t *testing.T, testFile string) bool
}{
{
name: "ok",
target: tsrv.Config.Hostname,
command: []string{"echo", "hello", ">", "test.txt"},
assertRun: assert.NoError,
assertCommandEffect: func(t *testing.T, testFile string) bool {
return assert.EventuallyWithT(t, func(t *assert.CollectT) {
assert.FileExists(t, testFile)
}, 3*time.Second, 100*time.Millisecond)
},
},
{
name: "stdin is closed after disowning",
target: tsrv.Config.Hostname,
command: []string{"read", "&&", "echo", "should not happen", ">", "test.txt"},
assertRun: assert.NoError,
assertCommandEffect: func(t *testing.T, testFile string) bool {
return assert.Never(t, func() bool {
_, err := os.Stat(testFile)
return !errors.Is(err, os.ErrNotExist)
}, 3*time.Second, time.Second)
},
},
{
name: "not allowed on multiple nodes",
target: "foo=bar",
command: []string{"echo", "hello", ">", "test.txt"},
assertRun: assert.Error,
},
{
name: "not allowed for interactive commands",
target: tsrv.Config.Hostname,
command: []string{},
assertRun: assert.Error,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Configure command with a real file path.
testFile := filepath.Join(t.TempDir(), "test.txt")
cmd := make([]string, 0, len(tc.command))
for _, arg := range tc.command {
cmd = append(cmd, strings.ReplaceAll(arg, "test.txt", testFile))
}
err := Run(t.Context(), append([]string{
"ssh",
"--insecure",
"-f",
u.Username + "@" + tc.target,
}, cmd...))
tc.assertRun(t, err)
if tc.assertCommandEffect != nil {
tc.assertCommandEffect(t, testFile)
}
})
}
}
func Test_humanFriendlyValidUntilDuration(t *testing.T) {
clock := clockwork.NewFakeClock()
tests := []struct {
name string
input time.Time
expect string
}{
{
name: "expired",
input: clock.Now().Add(-time.Minute),
expect: "EXPIRED",
},
{
name: "less than one minute",
input: clock.Now().Add(time.Second * 30),
expect: "valid for <1m",
},
{
name: "truncate",
input: clock.Now().Add(time.Minute*5 + time.Second*50),
expect: "valid for 5m",
},
{
name: "hours",
input: clock.Now().Add(time.Hour*12 + time.Minute*34 + time.Second*10),
expect: "valid for 12h34m",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual := humanFriendlyValidUntilDuration(test.input, clock)
require.Equal(t, test.expect, actual)
})
}
}
func TestDebugVersionOutput(t *testing.T) {
t.Setenv(tshBinMainTestEnv, "1")
testExecutable, err := os.Executable()
require.NoError(t, err)
output, err := exec.Command(testExecutable, "logout", "--debug").CombinedOutput()
require.NoError(t, err)
vers := []string{
teleport.Version,
teleport.Gitref,
runtime.Version(),
}
require.Contains(t, string(output), "Initializing tsh version")
for _, v := range vers {
require.Contains(t, string(output), v)
}
}
func TestLogoutOneIdentity(t *testing.T) {
tmpHomePath := t.TempDir()
connector := mockConnector(t)
alice, err := types.NewUser("[email protected]")
require.NoError(t, err)
alice.SetRoles([]string{"access"})
rootServer, err := testserver.NewTeleportProcess(
t.TempDir(),
testserver.WithBootstrap(connector, alice))
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, rootServer.Close())
require.NoError(t, rootServer.Wait())
})
authServer := rootServer.GetAuthServer()
require.NotNil(t, authServer)
proxyAddr, err := rootServer.ProxyWebAddr()
require.NoError(t, err)
tests := []struct {
name string
command []string
envMap map[string]string
}{
{
name: "--proxy flag set",
command: []string{"logout", "--proxy", proxyAddr.String()},
},
{
name: "TELEPORT_PROXY set",
command: []string{"logout"},
envMap: map[string]string{
proxyEnvVar: proxyAddr.String(),
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
for k, v := range tc.envMap {
t.Setenv(k, v)
}
err = Run(context.Background(), []string{
"login",
"--insecure",
"--proxy", proxyAddr.String()},
setHomePath(tmpHomePath),
setMockSSOLogin(authServer, alice, connector.GetName()))
require.NoError(t, err)
buf := bytes.NewBuffer([]byte{})
err := Run(context.Background(), tc.command,
setHomePath(tmpHomePath),
func(cf *CLIConf) error {
cf.OverrideStdout = buf
return nil
})
require.NoError(t, err)
require.Contains(t, buf.String(), fmt.Sprintf("Logged out %v from %v.\n", alice.GetName(), proxyAddr.Host()))
})
}
}
func TestListNodesCLIFlags(t *testing.T) {
testCases := []struct {
Description string
Args []string
ErrTestFunc func(e error) bool
}{
{
Description: "should return bad parameter error when --headless and --all are specified",
Args: []string{
"ls",
"--proxy", "test-proxy",
"--user", "test-user",
"--headless",
"--all",
},
ErrTestFunc: trace.IsBadParameter,
},
{
Description: "should return bad parameter error when --auth=headless and --all are specified",
Args: []string{
"ls",
"--proxy", "test-proxy",
"--user", "test-user",
"--auth", "headless",
"--all",
},
ErrTestFunc: trace.IsBadParameter,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
err := Run(t.Context(), testCase.Args)
require.True(t, testCase.ErrTestFunc(err))
})
}
}
func TestReexecErrorPropagation(t *testing.T) {
ctx := t.Context()
connector := mockConnector(t)
createAgent(t)
// Use a non-existent OS user to force reexec failures in the node.
missingLogin := "does-not-exist"
roleNodeAccessMissingLogin, err := types.NewRole("node-access-missing-login", types.RoleSpecV6{
Options: types.RoleOptions{
ForwardAgent: types.NewBool(true),
PermitX11Forwarding: types.NewBool(true),
},
Allow: types.RoleConditions{
Logins: []string{missingLogin},
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
require.NoError(t, err)
userMissingLogin, err := types.NewUser("user-missing-login")
require.NoError(t, err)
userMissingLogin.SetRoles([]string{roleNodeAccessMissingLogin.GetName()})
// When the user has mixed host user creation modes the error should be given additional context.
roleHostUserAllow, err := types.NewRole("node-access-allow-host-user", types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUserMode: types.CreateHostUserMode_HOST_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
Logins: []string{missingLogin},
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
})
require.NoError(t, err)
userHostUserCreationContext, err := types.NewUser("user-host-user-creation-context")
require.NoError(t, err)
userHostUserCreationContext.SetRoles([]string{roleNodeAccessMissingLogin.GetName(), roleHostUserAllow.GetName()})
sshHostname := "test-ssh-server"
rootServerOpts := []testserver.TestServerOptFunc{
testserver.WithBootstrap(connector, roleNodeAccessMissingLogin, roleHostUserAllow, userMissingLogin, userHostUserCreationContext),
testserver.WithHostname(sshHostname),
testserver.WithConfig(func(cfg *servicecfg.Config) {
cfg.SSH.Enabled = true
cfg.SSH.X11 = &x11.ServerConfig{
Enabled: true,
DisplayOffset: x11.DefaultDisplayOffset,
MaxDisplay: x11.DefaultMaxDisplays,
}
}),
}
rootServer, err := testserver.NewTeleportProcess(t.TempDir(), rootServerOpts...)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, rootServer.Close())
require.NoError(t, rootServer.Wait())
})
authServer := rootServer.GetAuthServer()
proxyAddr, err := rootServer.ProxyWebAddr()
require.NoError(t, err)
require.EventuallyWithT(t, func(t *assert.CollectT) {
rootNodes, err := rootServer.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
require.NoError(t, err)
require.Len(t, rootNodes, 1)
}, 10*time.Second, 100*time.Millisecond)
login := func(t *testing.T, loginUser types.User) string {
t.Helper()
homePath := t.TempDir()
err := Run(ctx, []string{
"login",
"--insecure",
"--proxy", proxyAddr.String(),
}, setHomePath(homePath), setMockSSOLogin(authServer, loginUser, connector.GetName()))
require.NoError(t, err)
return homePath
}
userMissingLoginHomePath := login(t, userMissingLogin)
userHostUserCreationContextHomePath := login(t, userHostUserCreationContext)
type testCase struct {
name string
tty bool
remoteCommand []string
}
sshShell := testCase{name: "ssh shell"}
sshCommand := testCase{
name: "ssh command",
remoteCommand: []string{"echo", "hello"},
}
sshCommandTTY := testCase{
name: "ssh command w/ tty",
remoteCommand: []string{"echo", "hello"},
tty: true,
}
runSSH := func(t *testing.T, tc testCase, homePath string, login string) (string, error) {
stdout := &output{buf: bytes.Buffer{}}
args := []string{"ssh", "--insecure"}
if tc.tty {
args = append(args, "--tty")
}
args = append(args, fmt.Sprintf("%s@%s", login, sshHostname))
args = append(args, tc.remoteCommand...)
// The shell should succeed even though agent/x11 forwarding will be denied.
err := Run(ctx, args,
setHomePath(homePath),
func(conf *CLIConf) error {
conf.OverrideStdout = stdout
return nil
},
)
return stdout.String(), err
}
t.Run("unknown user error", func(t *testing.T) {
t.Parallel()
homePath := userMissingLoginHomePath
unknownUserReexecError := fmt.Sprintf("Failed to launch: %v.\r\n", user.UnknownUserError(missingLogin))
for _, tc := range []testCase{sshShell, sshCommand, sshCommandTTY} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, err := runSSH(t, tc, homePath, missingLogin)
var exitCodeErr *common.ExitCodeError
require.ErrorAs(t, err, &exitCodeErr)
require.Equal(t, reexecconstants.RemoteCommandFailure, exitCodeErr.Code)
// Check for exact match to catch regressions with new lines.
require.Equal(t, unknownUserReexecError, stdout)
})
}
t.Run("sftp", func(t *testing.T) {
err := Run(ctx, []string{
"scp",
"--insecure",
"-q",
"-d",
"--no-resume",
fmt.Sprintf("%s@%s:%s", missingLogin, sshHostname, "/placeholder"),
t.TempDir(),
},
setHomePath(homePath),
func(conf *CLIConf) error {
conf.Relogin = false
return nil
},
)
require.Error(t, err)
// Check for exact match to catch regressions with new lines.
require.Equal(t, strings.ReplaceAll(unknownUserReexecError, "\r\n", "\n"), err.Error())
})
})
t.Run("error with host user creation context", func(t *testing.T) {
t.Parallel()
homePath := userHostUserCreationContextHomePath
contextualReexecErrorMessage := fmt.Sprintf("Failed to launch: %s: host user creation denied by the following resources: [%s: %q]\r\n",
user.UnknownUserError(missingLogin),
types.KindRole,
roleNodeAccessMissingLogin.GetName(),
)
for _, tc := range []testCase{sshCommand} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, err := runSSH(t, tc, homePath, missingLogin)
var exitCodeErr *common.ExitCodeError
require.ErrorAs(t, err, &exitCodeErr)
require.Equal(t, reexecconstants.RemoteCommandFailure, exitCodeErr.Code)
// Check for exact match to catch regressions with new lines.
require.Equal(t, contextualReexecErrorMessage, stdout)
})
}
t.Run("sftp", func(t *testing.T) {
err := Run(ctx, []string{
"scp",
"--insecure",
"-q",
"-d",
"--no-resume",
fmt.Sprintf("%s@%s:%s", missingLogin, sshHostname, "/placeholder"),
t.TempDir(),
},
setHomePath(homePath),
func(conf *CLIConf) error {
conf.Relogin = false
return nil
},
)
require.Error(t, err)
// Check for exact match to catch regressions with new lines.
require.Equal(t, strings.ReplaceAll(contextualReexecErrorMessage, "\r\n", "\n"), err.Error())
})
})
}