Skip to content

Commit

Permalink
Add tsh command to resolve a single host
Browse files Browse the repository at this point in the history
`tsh resolve` allows identifying a single host either directly by
hostname, or via custom search/predicate expression from a matched
proxy template. The main use case is to provide a simple command
for users that wish to use `Match exec` in their SSH config. Today,
all matching in SSH config must be done via DNS and requires users
to add some Teleport specific suffix/prefix, or use a wildcard entry
to invoke a tsh proxy command.

For example, the SSH config generated today via `tsh config` is the
following:

```
# Common flags for all local.dev hosts
Host *.cluster-name proxy.example.com
    UserKnownHostsFile "/Users/tim/.tsh/known_hosts"
    IdentityFile "/Users/tim/.tsh/keys/proxy.example.com/tim"
    CertificateFile "/Users/tim/.tsh/keys/proxy.example.com/tim-ssh/cluster-name-cert.pub"

# Flags for all local.dev hosts except the proxy
Host *.cluster-name
    Port 3022 !proxy.example.com
    ProxyCommand "tsh" proxy ssh --cluster=cluster-name --proxy=proxy.example.com:443 %r@%h:%p
```

This allows connections to the Teleport SSH service without using tsh directly
via `ssh foo.cluster-name`. However, when migrating to Teleport, that requires
the user to alter their existing workflow to include the cluster-name suffix.
To remedy this, users can now augment their SSH config to utilize `Match exec`
instead of globbing on the cluster name suffix with the following:

```
Match exec "tsh resolve -q %h"
    ProxyCommand "tsh" proxy ssh --cluster=cluster-name --proxy=proxy.example.com:443 %r@%h:%p
```

By default tsh resolve will output the matching host, if one was
found, but if the `-q` flag provided like in the example above
the output will be silenced. If no matches are found, or multiple
matches are found, `tsh resolve` will exit with a non-zero exit
code as per the `Match exec` requirements. If and only if a single
host is resolved will `tsh resolve` exit with a zero exit code.

There are performance concerns that need to be taken into account
before users adopt this in their SSH config. First, this may cause
`tsh resolve` to be invoked on any SSH request. For example, even
doing git pull/git push may now first require `tsh resolve` to
exit with a non-zero exit code before interact with the git remote.
Additionally, when `tsh resolve` finds a match, any connections to
the node will require _two_ connections to the cluster and _two_
ListUnifiedResourcesRequests to resolve the host since the
invocation of `tsh resolve` and `tsh proxy ssh` or `tbot proxy ssh`
do not share any resources. For the reasons mentioned above, the
SSH configuration generated by `tsh config` was not updated to
include this new command. If users want to opt into this behavior
they must acknowledge the latency concerns by manually editting
the config.
  • Loading branch information
rosstimothy committed Oct 18, 2024
1 parent 22edf09 commit e81e652
Show file tree
Hide file tree
Showing 2 changed files with 296 additions and 0 deletions.
105 changes: 105 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 {
os.Exit(1)
}
case latencySSH.FullCommand():
err = onSSHLatency(&cf)
case benchSSH.FullCommand():
Expand Down Expand Up @@ -3595,6 +3612,94 @@ 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: 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 @@ -3986,6 +3986,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 preset 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 @@ -6501,3 +6520,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)
}
})
}
}

0 comments on commit e81e652

Please sign in to comment.