diff --git a/lib/client/session.go b/lib/client/session.go index 3c8c0614e11cf..e65dcc3c6c377 100644 --- a/lib/client/session.go +++ b/lib/client/session.go @@ -555,13 +555,6 @@ func (ns *NodeSession) runCommand(ctx context.Context, mode types.SessionPartici ) defer span.End() - // If stdin is not a terminal, refuse to allocate terminal on the server and - // fallback to non-interactive mode - if interactive && !ns.terminal.IsAttached() { - interactive = false - fmt.Fprintf(ns.nodeClient.TC.Stderr, "TTY will not be allocated on the server because stdin is not a terminal\n") - } - // Start a interactive session ("exec" request with a TTY). // // Note that because a TTY was allocated, the terminal is in raw mode and any diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 894801b620fa4..f561bb41effa2 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -47,6 +47,7 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/dustin/go-humanize" "github.com/ghodss/yaml" + "github.com/google/uuid" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" "github.com/sirupsen/logrus" @@ -241,8 +242,17 @@ type CLIConf struct { DatabaseRoles string // AppName specifies proxied application name. AppName string - // Interactive, when set to true, launches remote command with the terminal attached + // Interactive sessions will allocate a PTY and create interactive "shell" + // sessions. Interactive bool + // NonInteractive sessions will not allocate a PTY and create + // non-interactive "exec" sessions. This variable is needed due to + // limitations in kingpin (lack of an inverse short flag) which forces + // the registration of both flags. + NonInteractive bool + // ShowVersion is an OpenSSH compatibility flag that prints out the version + // of tsh. Useful for users that alias ssh to "tsh ssh". + ShowVersion bool // Quiet mode, -q command (disables progress printing) Quiet bool // Namespace is used to select cluster namespace @@ -774,7 +784,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { // ssh // Use Interspersed(false) to forward all flags to ssh. ssh := app.Command("ssh", "Run shell or execute a command on a remote SSH node.").Interspersed(false) - ssh.Arg("[user@]host", "Remote hostname and the login to use").Required().StringVar(&cf.UserHost) + ssh.Arg("[user@]host", "Remote hostname and the login to use, this argument is required").StringVar(&cf.UserHost) ssh.Arg("command", "Command to execute on a remote host").StringsVar(&cf.RemoteCommand) app.Flag("jumphost", "SSH jumphost").Short('J').StringVar(&cf.ProxyJump) ssh.Flag("port", "SSH port on a remote host").Short('p').Int32Var(&cf.NodePort) @@ -799,6 +809,14 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { ssh.Flag("log-dir", "Directory to log separated command output, when executing on multiple nodes. If set, output from each node will also be labeled in the terminal.").StringVar(&cf.SSHLogDir) 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) + // The following flags are OpenSSH compatibility flags. They are used for + // users that alias "ssh" to "tsh ssh." The following OpenSSH flags are + // implemented. From "man 1 ssh": + // + // * "-V Display the version number and exit." + // * "-T Disable pseudo-terminal allocation." + ssh.Flag(uuid.New().String(), "").Short('T').Hidden().BoolVar(&cf.NonInteractive) + ssh.Flag(uuid.New().String(), "").Short('V').Hidden().BoolVar(&cf.ShowVersion) resolve := app.Command("resolve", "Resolves an SSH host.") resolve.Arg("host", "Remote hostname to resolve").Required().StringVar(&cf.UserHost) @@ -3655,6 +3673,31 @@ func onResolve(cf *CLIConf) error { // onSSH executes 'tsh ssh' command func onSSH(cf *CLIConf) error { + // If "tsh ssh -V" is invoked, tsh is in OpenSSH compatibility mode, show + // the version and exit. + if cf.ShowVersion { + modules.GetModules().PrintVersion() + return nil + } + + // If "tsh ssh" is invoked with the "-t" or "-T" flag, manually validate + // "-t" and "-T" flags for "tsh ssh" due to the lack of inverse short flags + // in kingpin. + if cf.Interactive && cf.NonInteractive { + return trace.BadParameter("either -t or -T can be specified, not both") + } + if cf.NonInteractive { + cf.Interactive = false + } + + // If "tsh ssh" is invoked the user must specify some host to connect to. + // In the past, this was handled by making "UserHost" required in kingpin. + // However, to support "tsh ssh -V" this was no longer possible. This + // property is how enforced in this function. + if cf.UserHost == "" { + return trace.BadParameter("required argument '[user@]host' not provided") + } + tc, err := makeClient(cf) if err != nil { return trace.Wrap(err) diff --git a/tool/tsh/common/tsh_test.go b/tool/tsh/common/tsh_test.go index 159487337e3b1..1dad2bb7d6691 100644 --- a/tool/tsh/common/tsh_test.go +++ b/tool/tsh/common/tsh_test.go @@ -6047,3 +6047,117 @@ func TestResolve(t *testing.T) { }) } } + +// 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("bar@example.com") + 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 ...interface{}) { + 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() + err := exec.Command(tshBin, "ssh", tt.flag, hostname, tty).Run() + tt.assertError(t, err) + }) + } +} + +// 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))) +}