Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tsh command to resolve a single host #47689

Merged
merged 1 commit into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions tool/tsh/common/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,11 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
ssh.Flag("no-resume", "Disable SSH connection resumption").Envar(noResumeEnvVar).BoolVar(&cf.DisableSSHResumption)
ssh.Flag("relogin", "Permit performing an authentication attempt on a failed command").Default("true").BoolVar(&cf.Relogin)

resolve := app.Command("resolve", "Resolves an SSH host.")
resolve.Arg("host", "Remote hostname to resolve").Required().StringVar(&cf.UserHost)
resolve.Flag("quiet", "Quiet mode").Short('q').BoolVar(&cf.Quiet)
resolve.Flag("format", defaults.FormatFlagDescription(defaults.DefaultFormats...)).Short('f').Default(teleport.Text).EnumVar(&cf.Format, defaults.DefaultFormats...)

// Daemon service for teleterm client
daemon := app.Command("daemon", "Daemon is the tsh daemon service.").Hidden()
daemonStart := daemon.Command("start", "Starts tsh daemon service.").Hidden()
Expand Down Expand Up @@ -1361,6 +1366,18 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
err = onVersion(&cf)
case ssh.FullCommand():
err = onSSH(&cf)
case resolve.FullCommand():
err = onResolve(&cf)
// If quiet was specified for this command and
// an error occurred, exit with a non-zero exit
// code without emitting any other messaging.
// In this case, the command was likely invoked
// via a Match exec block from an SSH config and
// if no matches were found, we should not add
// additional spam to stderr.
if err != nil && cf.Quiet {
err = trace.Wrap(&common.ExitCodeError{Code: 1})
}
case latencySSH.FullCommand():
err = onSSHLatency(&cf)
case benchSSH.FullCommand():
Expand Down Expand Up @@ -3595,6 +3612,95 @@ func runLocalCommand(hostLogin string, command []string) error {
return cmd.Run()
}

// onResolve executes `tsh resolve`, a command that
// attempts to resolve a single host from a provided
// hostname. The host information provided may be
// interpolated by proxy templates and converted
// from a hostname into a fuzzy search, or predicate query.
// Errors are returned if unable to connect to the cluster,
// no matching hosts were found, or multiple matching hosts
// were found. This is primarily meant to be used as a command
// for a match exec block in an SSH config.
func onResolve(cf *CLIConf) error {
tc, err := makeClient(cf)
if err != nil {
return trace.Wrap(err)
}

req := proto.ListUnifiedResourcesRequest{
Kinds: []string{types.KindNode},
Labels: tc.Labels,
SearchKeywords: tc.SearchKeywords,
PredicateExpression: tc.PredicateExpression,
UseSearchAsRoles: tc.UseSearchAsRoles,
SortBy: types.SortBy{Field: types.ResourceKind},
// Limit to 2 so we can check for an ambiguous result
Limit: 2,
}

// If no search criteria were explicitly provided, then match exclusively
// on the hostname of the server. Otherwise, this would end up listing
// the first two servers that the user has access to and yield unexpected results.
if len(tc.Labels) == 0 && len(tc.SearchKeywords) == 0 && tc.PredicateExpression == "" {
req.PredicateExpression = fmt.Sprintf(`name == "%s"`, tc.Host)
}

// Only enable the re-authentication behavior if not invoked with `-q`. When
// in quiet mode, this command is likely being invoked via ssh and
// the login prompt will not be able to be presented to users anyway.
executor := client.RetryWithRelogin
if cf.Quiet {
executor = func(ctx context.Context, teleportClient *client.TeleportClient, f func() error, option ...client.RetryWithReloginOption) error {
return f()
}
}

var page []*types.EnrichedResource
if err := executor(cf.Context, tc, func() error {
clt, err := tc.ConnectToCluster(cf.Context)
if err != nil {
return trace.Wrap(err)
}

defer clt.Close()

page, _, err = apiclient.GetUnifiedResourcePage(cf.Context, clt.AuthClient, &req)
if err != nil {
return trace.Wrap(err)
}

return nil
}); err != nil {
return trace.Wrap(err)
}

switch len(page) {
case 1:
case 0:
return trace.NotFound("no matching hosts found")
default:
return trace.BadParameter("multiple matching hosts found")
}

if cf.Quiet {
return nil
}

format := strings.ToLower(cf.Format)
switch format {
case teleport.Text, "":
printNodesAsText(cf.Stdout(), []types.Server{page[0].ResourceWithLabels.(types.Server)}, true)
case teleport.JSON:
utils.WriteJSON(cf.Stdout(), page[0].ResourceWithLabels)
case teleport.YAML:
utils.WriteYAML(cf.Stdout(), page[0].ResourceWithLabels)
default:
return trace.BadParameter("unsupported format %q", cf.Format)
}

return nil
}

// onSSH executes 'tsh ssh' command
func onSSH(cf *CLIConf) error {
tc, err := makeClient(cf)
Expand Down
191 changes: 191 additions & 0 deletions tool/tsh/common/tsh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4012,6 +4012,25 @@ func setCopyStdout(stdout io.Writer) CliOption {
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
}
}
Expand Down Expand Up @@ -6527,3 +6546,175 @@ func TestRolesToString(t *testing.T) {
})
}
}

// TestResolve tests that host resolution works for various inputs and
// that proxy templates are respected.
func TestResolve(t *testing.T) {
modules.SetTestModules(t, &modules.TestModules{TestBuildType: modules.BuildEnterprise})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

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(t, "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 := testserver.MakeTestServer(t, rootServerOpts...)

node := testserver.MakeTestServer(t,
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",
}
}))

rootProxyAddr, err := rootServer.ProxyWebAddr()
require.NoError(t, err)

require.EventuallyWithT(t, func(t *assert.CollectT) {
found, err := rootServer.GetAuthServer().GetNodes(ctx, apidefaults.Namespace)
if !assert.NoError(t, err) || !assert.Len(t, found, 2) {
return
}
}, 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 ...interface{}) {
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 ...interface{}) {
require.Error(tt, err, i...)
require.ErrorContains(tt, err, "multiple matching hosts", i...)
},
},
}

for _, test := range tests {
test := test
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)
}
})
}
}
Loading