summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorPercy Wegmann <percy@tailscale.com>2024-04-26 19:29:59 -0500
committerPercy Wegmann <percy@tailscale.com>2024-04-27 14:54:26 -0500
commitcc0bd0229dd03eee73df3e7adb464bcc8f14ca8f (patch)
treeba2f3c823b94bc6f502bb4652e71ff5079cf1528
parentfee3aeb7f2ce6562234acac26bbf1c1fba9e0d14 (diff)
downloadtailscale-ox/11854.tar.xz
tailscale-ox/11854.zip
ssh/tailssh: add integration tests for sshox/11854
Adds basic integration tests for beIncubator that can run on: - MacOS - Ubuntu - Fedora Updates #11854 Signed-off-by: Percy Wegmann <percy@tailscale.com>
-rw-r--r--.github/workflows/test.yml10
-rw-r--r--Makefile11
-rw-r--r--ssh/tailssh/incubator.go333
-rw-r--r--ssh/tailssh/incubator_test.go115
-rw-r--r--ssh/tailssh/testcontainers/Dockerfile11
5 files changed, 341 insertions, 139 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e2525d0a1..f3942e6ec 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -575,6 +575,16 @@ jobs:
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ ssh:
+ runs-on: ubuntu-22.04
+ steps:
+ - name: checkout
+ uses: actions/checkout@v4
+ - name: run ssh tests
+ run: |
+ export PATH=$(./tool/go env GOROOT)/bin:$PATH
+ make sshintegrationtest
+
check_mergeability:
if: always()
runs-on: ubuntu-22.04
diff --git a/Makefile b/Makefile
index 8f8bbe2b4..c614539ef 100644
--- a/Makefile
+++ b/Makefile
@@ -100,6 +100,17 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
+.PHONY: sshintegrationtest
+sshintegrationtest:
+ GOOS=linux GOARCH=amd64 go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
+ echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
+ echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
+ echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
+ echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
+ echo "Testing on fedora:38" && docker build --build-arg="BASE=dokken/fedora-38" -t ssh-fedora-38 ssh/tailssh/testcontainers && \
+ echo "Testing on fedora:39" && docker build --build-arg="BASE=dokken/fedora-39" -t ssh-fedora-39 ssh/tailssh/testcontainers && \
+ echo "Testing on fedora:40" && docker build --build-arg="BASE=dokken/fedora-40" -t ssh-fedora-40 ssh/tailssh/testcontainers
+
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
diff --git a/ssh/tailssh/incubator.go b/ssh/tailssh/incubator.go
index b4e1a371f..8aa8e70df 100644
--- a/ssh/tailssh/incubator.go
+++ b/ssh/tailssh/incubator.go
@@ -134,6 +134,8 @@ func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
const debugIncubator = false
+var runningInTest = false
+
type stdRWC struct{}
func (stdRWC) Read(p []byte) (n int, err error) {
@@ -162,6 +164,10 @@ type incubatorArgs struct {
isSFTP bool
isShell bool
cmdArgs []string
+ env []string
+ stdin io.ReadCloser
+ stdout io.WriteCloser
+ stderr io.WriteCloser
}
func parseIncubatorArgs(args []string) (a incubatorArgs) {
@@ -183,15 +189,16 @@ func parseIncubatorArgs(args []string) (a incubatorArgs) {
}
// beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand.
-// It is responsible for informing the system of a new login session for the user.
-// This is sometimes necessary for mounting home directories and decrypting file
-// systems.
+// It is responsible for informing the system of a new login session for the
+// user. This is sometimes necessary for mounting home directories and
+// decrypting file systems.
//
-// Tailscaled launches the incubator as the same user as it was
-// launched as. The incubator then registers a new session with the
-// OS, sets its UID and groups to the specified `--uid`, `--gid` and
-// `--groups` and then launches the requested `--cmd`.
+// Tailscaled launches the incubator as the same user as it was launched as.
func beIncubator(args []string) error {
+ return doBeIncubator(args, os.Environ(), os.Stdin, os.Stdout, os.Stderr)
+}
+
+func doBeIncubator(args []string, env []string, stdin io.ReadCloser, stdout, stderr io.WriteCloser) error {
// To defend against issues like https://golang.org/issue/1435,
// defensively lock our current goroutine's thread to the current
// system thread before we start making any UID/GID/group changes.
@@ -202,21 +209,37 @@ func beIncubator(args []string) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
- ia := parseIncubatorArgs(args)
- if ia.isSFTP && ia.isShell {
- return fmt.Errorf("--sftp and --shell are mutually exclusive")
- }
-
logf := logger.Discard
if debugIncubator {
// We don't own stdout or stderr, so the only place we can log is syslog.
if sl, err := syslog.New(syslog.LOG_INFO|syslog.LOG_DAEMON, "tailscaled-ssh"); err == nil {
logf = log.New(sl, "", 0).Printf
}
+ } else if runningInTest {
+ // We can log to stdout during testing
+ logf = log.Printf
+ }
+
+ ia := parseIncubatorArgs(args)
+ ia.env = env
+ ia.stdin = stdin
+ ia.stdout = stdout
+ ia.stderr = stderr
+ if ia.isSFTP && ia.isShell {
+ return fmt.Errorf("--sftp and --shell are mutually exclusive")
+ }
+
+ if ia.isSFTP {
+ return handleFTP(logf)
}
- if handled, err := tryLoginShell(logf, ia); handled {
+ attemptLoginShell := shouldAttemptLoginShell()
+ if !attemptLoginShell {
+ logf("not attempting login shell")
+ } else if handled, err := tryLoginCmd(logf, ia); handled {
return err
+ } else {
+ logf("not attempting login command")
}
// Inform the system that we are about to log someone in.
@@ -228,156 +251,103 @@ func beIncubator(args []string) error {
defer sessionCloser()
}
- var groupIDs []int
- for _, g := range strings.Split(ia.groups, ",") {
- gid, err := strconv.ParseInt(g, 10, 32)
- if err != nil {
- return err
- }
- groupIDs = append(groupIDs, int(gid))
- }
-
- if err := dropPrivileges(logf, ia.uid, ia.gid, groupIDs); err != nil {
- return err
- }
-
- if ia.isSFTP {
- logf("handling sftp")
-
- server, err := sftp.NewServer(stdRWC{})
- if err != nil {
- return err
- }
- // TODO(https://github.com/pkg/sftp/pull/554): Revert the check for io.EOF,
- // when sftp is patched to report clean termination.
- if err := server.Serve(); err != nil && err != io.EOF {
+ if attemptLoginShell {
+ // We weren't able to use login, maybe we can use su.
+ if handled, err := tryLoginWithSU(logf, ia); handled {
return err
+ } else {
+ logf("not attempting su")
}
- return nil
}
- cmd := exec.Command(ia.cmdName, ia.cmdArgs...)
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- cmd.Env = os.Environ()
-
- if ia.hasTTY {
- // If we were launched with a tty then we should
- // mark that as the ctty of the child. However,
- // as the ctty is being passed from the parent
- // we set the child to foreground instead which
- // also passes the ctty.
- // However, we can not do this if never had a tty to
- // begin with.
- cmd.SysProcAttr = &syscall.SysProcAttr{
- Foreground: true,
- }
- }
- err = cmd.Run()
- if ee, ok := err.(*exec.ExitError); ok {
- ps := ee.ProcessState
- code := ps.ExitCode()
- if code < 0 {
- // TODO(bradfitz): do we need to also check the syscall.WaitStatus
- // and make our process look like it also died by signal/same signal
- // as our child process? For now we just do the exit code.
- fmt.Fprintf(os.Stderr, "[tailscale-ssh: process died: %v]\n", ps.String())
- code = 1 // for now. so we don't exit with negative
- }
- os.Exit(code)
- }
- return err
+ // We couldn't use su either, fall back to just dropping privileges.
+ return handleDropPrivileges(logf, ia)
}
-// tryLoginShell attempts to handle the ssh session by creating a full login
-// shell. If it was able to do so, it returns true plus any error from running
-// that shell. If it was unable to do so, it returns false, nil.
-//
-// We prefer to create a login shell using either the login command
-// (e.g. /usr/bin/login) or using the su command (e.g. /usr/bin/su). A login
-// shell has the advantage of running PAM authentication, which will set up the
-// connected user's environment. See https://github.com/tailscale/tailscale/issues/11854.
-//
-// login is preferred over su because it supports the `-h` option, allowing the
-// system to record the remote IP associated with the login.
-//
-// However, login is subject to some limitations.
-//
-// 1. login cannot be used to execute commands except on macOS.
-// 2. On Linux and BSD, login requires a TTY to keep running.
-//
-// Unlike login, su often does not require a TTY, so on Linux hosts that have
-// an su command which accepts the right flags, we fall back to using that when
-// no TTY is available.
-//
-// Note - one nuance of this is that when we use login with the -h option, the
-// shell will use the "remote" PAM profile. When we fall back to using "su",
-// the shell will use the "login" PAM profile.
-func tryLoginShell(logf logger.Logf, ia incubatorArgs) (bool, error) {
+// shouldAttemptLoginShell decides whether we should attempt to get a full
+// login shell with the login or su commands.
+func shouldAttemptLoginShell() bool {
euid := os.Geteuid()
runningAsRoot := euid == 0
- // Decide whether we should attempt to get a full login shell using either
- // the login or su commands.
- attemptLoginShell := true
- switch {
- case ia.isSFTP:
- // If we're going to run an sFTP server, we don't want a shell
- attemptLoginShell = false
- case !runningAsRoot:
+ if !runningAsRoot {
// We have to be root in order to create a login shell.
- attemptLoginShell = false
- case hostinfo.IsSELinuxEnforcing():
+ return false
+ }
+ if hostinfo.IsSELinuxEnforcing() {
// If we're running on a SELinux-enabled system, neiher login nor su
// will be able to set the correct context for the shell. So, we don't
// bother trying to run them and instead fall back to using the
// incubator to launch the shell.
// See http://github.com/tailscale/tailscale/issues/4908.
- attemptLoginShell = false
+ return false
}
- if !attemptLoginShell {
- logf("not attempting login shell")
+ return true
+}
+
+// tryLoginCmd attempts to handle the ssh session by creating a full login
+// shell using the login command. If it was able to do so, it returns true,
+// plus any error from running that shell. If it was unable to do so, it
+// returns false, nil.
+//
+// Creating a login shell in this way allows us to register the remote IP of
+// the login session, trigger PAM authentication, and get the "remote" PAM
+// profile.
+//
+// However, login is subject to some limitations.
+//
+// 1. login cannot be used to execute commands except on macOS.
+// 2. On Linux and BSD, login requires a TTY to keep running.
+//
+// In these cases, tryLoginCmd returns false, nil to indicate that processing
+// should fall through to other methods, such as using the su command.
+func tryLoginCmd(logf logger.Logf, ia incubatorArgs) (bool, error) {
+ // Only the macOS version of the login command supports executing a
+ // command, all other versions only support launching a shell without
+ // taking any arguments.
+ if !ia.isShell && runtime.GOOS != "darwin" {
return false, nil
}
- shouldUseLoginCmd := ia.isShell || runtime.GOOS == "darwin"
switch runtime.GOOS {
case "linux", "freebsd", "openbsd":
if !ia.hasTTY {
- // We can only use login command if a shell was requested with a TTY. If
- // there is no TTY, login exits immediately, which breaks things likes
- // mosh and VSCode.
- shouldUseLoginCmd = false
+ // We can only use the login command if a shell was requested with
+ // a TTY. If there is no TTY, login exits immediately, which
+ // breaks things like mosh and VSCode.
+ return false, nil
}
}
- if shouldUseLoginCmd {
- if loginCmdPath, err := exec.LookPath("login"); err == nil {
- logf("using %s command", loginCmdPath)
- return true, unix.Exec(loginCmdPath, ia.loginArgs(loginCmdPath), os.Environ())
- }
+ if loginCmdPath, err := exec.LookPath("login"); err == nil {
+ loginArgs := ia.loginArgs(loginCmdPath)
+ logf("logging in with %s %+v", loginCmdPath, loginArgs)
+ return true, execReplacing(ia, loginCmdPath, loginArgs)
}
- // We weren't able to use login, maybe we can use su.
+ return false, nil
+}
+
+// tryLoginWithSU attempts to start a login shell using su. If su is available
+// and supports the necessary arguments, this returns true, plus the result of
+// executing su. Otherwise, it returns false, nil.
+//
+// Creating a login shell in this way allows us to trigger PAM authentication
+// and get the "login" PAM profile.
+//
+// Unlike login, su often does not require a TTY, so on Linux hosts that have
+// an su command which accepts the right flags, we'll use su instead of login
+// when no TTY is available.
+func tryLoginWithSU(logf logger.Logf, ia incubatorArgs) (bool, error) {
// Currently, we only support falling back to su on Linux. This
// potentially could work on BSDs as well, but requires testing.
- canUseSU := runtime.GOOS == "linux"
- if !canUseSU {
- logf("not attempting su")
+ if runtime.GOOS != "linux" {
return false, nil
}
- return tryLoginWithSU(logf, ia)
-}
-// tryLoginWithSU attempts to start a login shell using su instead of login. If
-// su is available and supports the necessary arguments, this returns true,
-// plus the result of executing su. Otherwise, it returns false, nil.
-func tryLoginWithSU(logf logger.Logf, ia incubatorArgs) (bool, error) {
su, err := exec.LookPath("su")
if err != nil {
- // Can't find su, don't bother trying.
logf("can't find su command")
return false, nil
}
@@ -386,7 +356,6 @@ func tryLoginWithSU(logf logger.Logf, ia incubatorArgs) (bool, error) {
out, err := exec.Command(su, "-h").CombinedOutput()
if err != nil {
logf("%s doesn't support -h, don't use", su)
- // Can't even call su -h, don't bother trying.
return false, nil
}
@@ -395,22 +364,22 @@ func tryLoginWithSU(logf logger.Logf, ia incubatorArgs) (bool, error) {
}
// Make sure su supports the necessary flags.
- if !supportsFlag("-l") {
- logf("%s doesn't support -l, don't use", su)
+ if !supportsFlag("--login") {
+ logf("%s doesn't support --login, don't use", su)
return false, nil
}
- if !supportsFlag("-c") {
- logf("%s doesn't support -c, don't use", su)
+ if !supportsFlag("--command") {
+ logf("%s doesn't support --command, don't use", su)
return false, nil
}
loginArgs := []string{
- "-l",
+ "--login",
}
- if ia.hasTTY && supportsFlag("-P") {
+ if ia.hasTTY && supportsFlag("--pty") {
// Allocate a pseudo terminal for improved security. In particular,
// this can help avoid TIOCSTI ioctl terminal injection.
- loginArgs = append(loginArgs, "-P")
+ loginArgs = append(loginArgs, "--pty")
}
loginArgs = append(loginArgs, ia.localUser)
@@ -419,12 +388,98 @@ func tryLoginWithSU(logf logger.Logf, ia incubatorArgs) (bool, error) {
// shell. When requesting a shell, the command is the requested shell,
// which is redundant because `su -l` will give the user their default
// shell.
- loginArgs = append(loginArgs, "-c", ia.cmdName)
+ loginArgs = append(loginArgs, "--command", ia.cmdName)
loginArgs = append(loginArgs, ia.cmdArgs...)
}
- logf("logging in with su %+v", loginArgs)
- return true, unix.Exec("/usr/bin/su", loginArgs, os.Environ())
+ logf("logging in with %s %+v", su, loginArgs)
+ return true, execReplacing(ia, su, loginArgs)
+}
+
+// handleFTP serves FTP connections.
+func handleFTP(logf logger.Logf) error {
+ logf("handling sftp")
+
+ server, err := sftp.NewServer(stdRWC{})
+ if err != nil {
+ return err
+ }
+ // TODO(https://github.com/pkg/sftp/pull/554): Revert the check for io.EOF,
+ // when sftp is patched to report clean termination.
+ if err := server.Serve(); err != nil && err != io.EOF {
+ return err
+ }
+ return nil
+}
+
+// handleDropPrivileges is a last resort if we couldn't use login or su.
+// It registers a new session with the OS, sets its UID, GID and groups
+// to the specified values, and then launches the requested `--cmd`.
+func handleDropPrivileges(logf logger.Logf, ia incubatorArgs) error {
+ var groupIDs []int
+ for _, g := range strings.Split(ia.groups, ",") {
+ gid, err := strconv.ParseInt(g, 10, 32)
+ if err != nil {
+ return err
+ }
+ groupIDs = append(groupIDs, int(gid))
+ }
+
+ if err := dropPrivileges(logf, ia.uid, ia.gid, groupIDs); err != nil {
+ return err
+ }
+
+ logf("running %s %+v", ia.cmdName, ia.cmdArgs)
+ cmd := exec.Command(ia.cmdName, ia.cmdArgs...)
+ cmd.Stdin = ia.stdin
+ cmd.Stdout = ia.stdout
+ cmd.Stderr = ia.stderr
+ cmd.Env = ia.env
+
+ if ia.hasTTY {
+ // If we were launched with a tty then we should
+ // mark that as the ctty of the child. However,
+ // as the ctty is being passed from the parent
+ // we set the child to foreground instead which
+ // also passes the ctty.
+ // However, we can not do this if never had a tty to
+ // begin with.
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Foreground: true,
+ }
+ }
+ err := cmd.Run()
+ if ee, ok := err.(*exec.ExitError); ok {
+ ps := ee.ProcessState
+ code := ps.ExitCode()
+ if code < 0 {
+ // TODO(bradfitz): do we need to also check the syscall.WaitStatus
+ // and make our process look like it also died by signal/same signal
+ // as our child process? For now we just do the exit code.
+ fmt.Fprintf(os.Stderr, "[tailscale-ssh: process died: %v]\n", ps.String())
+ code = 1 // for now. so we don't exit with negative
+ }
+ os.Exit(code)
+ }
+ return err
+}
+
+// execReplacing executes the given cmdPath replacing the current process.
+// When testing on macOS, it does not replace the current process.
+func execReplacing(ia incubatorArgs, cmdPath string, args []string) error {
+ if runningInTest && runtime.GOOS == "darwin" {
+ // replacing the running process doesn't work when integration testing
+ // on macOS, so we use exec.Cmd instead.
+ cmd := exec.Command(cmdPath, args[1:]...)
+ cmd.Stdin = ia.stdin
+ cmd.Stdout = ia.stdout
+ cmd.Stderr = ia.stderr
+ cmd.Env = ia.env
+ return cmd.Run()
+ }
+
+ // replace the running process
+ return unix.Exec(cmdPath, args, ia.env)
}
const (
diff --git a/ssh/tailssh/incubator_test.go b/ssh/tailssh/incubator_test.go
new file mode 100644
index 000000000..5f2c31f09
--- /dev/null
+++ b/ssh/tailssh/incubator_test.go
@@ -0,0 +1,115 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build integrationtest
+// +build integrationtest
+
+package tailssh
+
+import (
+ "bufio"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "os/user"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+// TestBeIncubator runs an integration test of the beIncubator function. It
+// expects an execution environment that meets the following requirements:
+//
+// - OS is one of MacOS, Linux, FreeBSD or OpenBSD
+// - User "testuser" exists
+// - "theuser" is in groups "groupone" and "grouptwo"
+func TestIntegrationBeIncubator(t *testing.T) {
+ runningInTest = true
+ t.Cleanup(func() {
+ runningInTest = false
+ })
+
+ testuser, err := user.Lookup("testuser")
+ if err != nil {
+ t.Fatal(err)
+ }
+ groupone, err := user.LookupGroup("groupone")
+ if err != nil {
+ t.Fatal(err)
+ }
+ grouptwo, err := user.LookupGroup("grouptwo")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ runCmd := func(cmd string) string {
+ errCh := make(chan error, 1)
+ defer func() {
+ select {
+ case err := <-errCh:
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ }()
+
+ args := []string{
+ "--uid", testuser.Uid,
+ "--gid", testuser.Gid,
+ "--groups", groupone.Gid + "," + grouptwo.Gid,
+ "--local-user", "testuser",
+ "--remote-user", "remoteuser",
+ "--remote-ip", "192.168.1.180",
+ "--cmd", cmd,
+ }
+
+ log.Printf("Testing with args %+v", args)
+
+ stdinReader, stdin := io.Pipe()
+ stdoutReader, stdoutWriter := io.Pipe()
+ stderrReader, stderrWriter := io.Pipe()
+ defer stdin.Close()
+ defer stdoutReader.Close()
+ defer stderrReader.Close()
+
+ go func() {
+ errCh <- doBeIncubator(args, os.Environ(), stdinReader, stdoutWriter, stderrWriter)
+ }()
+
+ stdout := bufio.NewReader(stdoutReader)
+ go io.Copy(os.Stderr, stderrReader)
+ result, err := stdout.ReadString('\n')
+ if err != nil {
+ t.Fatal(err)
+ }
+ return strings.TrimSpace(result)
+ }
+
+ gotId := runCmd("id")
+ if !strings.Contains(gotId, "testuserd") {
+ t.Logf("id output %q missing testuser", gotId)
+ }
+ if !strings.Contains(gotId, "groupone") {
+ t.Logf("id output %q missing groupone", gotId)
+ }
+ if !strings.Contains(gotId, "grouptwo") {
+ t.Logf("id output %q missing grouptwo", gotId)
+ }
+
+ _, err = exec.LookPath("su")
+ if err == nil {
+ // If su command is present, make sure that pwd without TTY shows the
+ // correct directory.
+ gotPwd := runCmd("pwd")
+ wantPwd := "/home/testuserd"
+ if runtime.GOOS == "darwin" {
+ wantPwd = "/Users/testuser"
+ }
+ if diff := cmp.Diff(gotPwd, wantPwd); diff != "" {
+ t.Fatalf("unexpected pwd output (-got +want):\n%s", diff)
+ }
+ }
+}
diff --git a/ssh/tailssh/testcontainers/Dockerfile b/ssh/tailssh/testcontainers/Dockerfile
new file mode 100644
index 000000000..e782af55e
--- /dev/null
+++ b/ssh/tailssh/testcontainers/Dockerfile
@@ -0,0 +1,11 @@
+ARG BASE
+FROM ${BASE}
+
+RUN groupadd -g 10000 groupone
+RUN groupadd -g 10001 grouptwo
+RUN useradd -g 10000 -G 10001 -u 10002 -m testuser
+COPY . .
+RUN ./tailssh.test -test.run TestIntegration
+# Remove the su command and run the test again to make sure it works without su
+RUN rm `which su`
+RUN ./tailssh.test -test.run TestIntegration