Skip to content

Commit

Permalink
Added support for OpenSSH compatibility flags. (#46879) (#48019)
Browse files Browse the repository at this point in the history
Added support for OpenSSH compatibility flags for interactivity (-t and
-T) and version information (-V). This is for customers that alias "ssh"
to "tsh ssh".
  • Loading branch information
russjones authored Oct 29, 2024
1 parent 79184f6 commit 3bee07f
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 9 deletions.
7 changes: 0 additions & 7 deletions lib/client/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 45 additions & 2 deletions tool/tsh/common/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
114 changes: 114 additions & 0 deletions tool/tsh/common/tsh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("[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 ...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)))
}

0 comments on commit 3bee07f

Please sign in to comment.