diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index b02091d75a8a..d7573c092a9d 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -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() @@ -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(): @@ -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) diff --git a/tool/tsh/common/tsh_test.go b/tool/tsh/common/tsh_test.go index 0cc5e07fc9aa..e7662c5dd03e 100644 --- a/tool/tsh/common/tsh_test.go +++ b/tool/tsh/common/tsh_test.go @@ -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 } } @@ -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) + } + }) + } +}