// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause // This file contains the code for the incubator process. 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`. //go:build linux || (darwin && !ios) || freebsd || openbsd package tailssh import ( "bytes" "errors" "flag" "fmt" "io" "log" "log/syslog" "os" "os/exec" "path/filepath" "runtime" "slices" "sort" "strconv" "strings" "syscall" "github.com/creack/pty" "github.com/pkg/sftp" "github.com/u-root/u-root/pkg/termios" gossh "golang.org/x/crypto/ssh" "golang.org/x/sys/unix" "tailscale.com/cmd/tailscaled/childproc" "tailscale.com/hostinfo" "tailscale.com/tempfork/gliderlabs/ssh" "tailscale.com/types/logger" "tailscale.com/version/distro" ) func init() { childproc.Add("ssh", beIncubator) } var ptyName = func(f *os.File) (string, error) { return "", fmt.Errorf("unimplemented") } // maybeStartLoginSession starts a new login session for the specified UID. // On success, it may return a non-nil close func which must be closed to // release the session. // See maybeStartLoginSessionLinux. var maybeStartLoginSession = func(logf logger.Logf, ia incubatorArgs) (close func() error, err error) { return nil, nil } // newIncubatorCommand returns a new exec.Cmd configured with // `tailscaled be-child ssh` as the entrypoint. // // If ss.srv.tailscaledPath is empty, this method is equivalent to // exec.CommandContext. // // The returned Cmd.Env is guaranteed to be nil; the caller populates it. func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) { defer func() { if cmd.Env != nil { panic("internal error") } }() var ( name string args []string isSFTP bool isShell bool ) switch ss.Subsystem() { case "sftp": isSFTP = true case "": name = ss.conn.localUser.LoginShell() if rawCmd := ss.RawCommand(); rawCmd != "" { args = append(args, "-c", rawCmd) } else { isShell = true args = append(args, "-l") // login shell } default: panic(fmt.Sprintf("unexpected subsystem: %v", ss.Subsystem())) } if ss.conn.srv.tailscaledPath == "" { // TODO(maisem): this doesn't work with sftp return exec.CommandContext(ss.ctx, name, args...) } lu := ss.conn.localUser ci := ss.conn.info gids := strings.Join(ss.conn.userGroupIDs, ",") remoteUser := ci.uprof.LoginName if ci.node.IsTagged() { remoteUser = strings.Join(ci.node.Tags().AsSlice(), ",") } incubatorArgs := []string{ "be-child", "ssh", "--uid=" + lu.Uid, "--gid=" + lu.Gid, "--groups=" + gids, "--local-user=" + lu.Username, "--remote-user=" + remoteUser, "--remote-ip=" + ci.src.Addr().String(), "--has-tty=false", // updated in-place by startWithPTY "--tty-name=", // updated in-place by startWithPTY } if isSFTP { incubatorArgs = append(incubatorArgs, "--sftp") } else { if isShell { incubatorArgs = append(incubatorArgs, "--shell") } incubatorArgs = append(incubatorArgs, "--cmd="+name) if len(args) > 0 { incubatorArgs = append(incubatorArgs, "--") incubatorArgs = append(incubatorArgs, args...) } } return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...) } const debugIncubator = false type stdRWC struct{} func (stdRWC) Read(p []byte) (n int, err error) { return os.Stdin.Read(p) } func (stdRWC) Write(b []byte) (n int, err error) { return os.Stdout.Write(b) } func (stdRWC) Close() error { os.Exit(0) return nil } type incubatorArgs struct { uid int gid int groups string localUser string remoteUser string remoteIP string ttyName string hasTTY bool cmdName string isSFTP bool isShell bool cmdArgs []string } func parseIncubatorArgs(args []string) (a incubatorArgs) { flags := flag.NewFlagSet("", flag.ExitOnError) flags.IntVar(&a.uid, "uid", 0, "the uid of local-user") flags.IntVar(&a.gid, "gid", 0, "the gid of local-user") flags.StringVar(&a.groups, "groups", "", "comma-separated list of gids of local-user") flags.StringVar(&a.localUser, "local-user", "", "the user to run as") flags.StringVar(&a.remoteUser, "remote-user", "", "the remote user/tags") flags.StringVar(&a.remoteIP, "remote-ip", "", "the remote Tailscale IP") flags.StringVar(&a.ttyName, "tty-name", "", "the tty name (pts/3)") flags.BoolVar(&a.hasTTY, "has-tty", false, "is the output attached to a tty") flags.StringVar(&a.cmdName, "cmd", "", "the cmd to launch (ignored in sftp mode)") flags.BoolVar(&a.isShell, "shell", false, "is launching a shell (with no cmds)") flags.BoolVar(&a.isSFTP, "sftp", false, "run sftp server (cmd is ignored)") flags.Parse(args) a.cmdArgs = flags.Args() return a } // 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. // // 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`. func beIncubator(args []string) 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. // // This shouldn't matter on Linux because syscall.AllThreadsSyscall is // used to invoke syscalls on all OS threads, but (as of 2023-03-23) // that function is not implemented on all platforms. 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 } } if handled, err := tryLoginShell(logf, ia); handled { return err } // Inform the system that we are about to log someone in. // We can only do this if we are running as root. // This is best effort to still allow running on machines where // we don't support starting sessions, e.g. darwin. sessionCloser, err := maybeStartLoginSession(logf, ia) if err == nil && sessionCloser != nil { 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 { return err } 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 } // 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) { 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: // We have to be root in order to create a login shell. attemptLoginShell = false case 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 } if !attemptLoginShell { logf("not attempting login shell") 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 } } 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()) } } // We weren't able to use login, maybe we can use su. // 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") 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 } // Get help text to inspect supported flags. 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 } supportsFlag := func(flag string) bool { return bytes.Contains(out, []byte(flag)) } // Make sure su supports the necessary flags. if !supportsFlag("-l") { logf("%s doesn't support -l, don't use", su) return false, nil } if !supportsFlag("-c") { logf("%s doesn't support -c, don't use", su) return false, nil } loginArgs := []string{ "-l", } if ia.hasTTY && supportsFlag("-P") { // Allocate a pseudo terminal for improved security. In particular, // this can help avoid TIOCSTI ioctl terminal injection. loginArgs = append(loginArgs, "-P") } loginArgs = append(loginArgs, ia.localUser) if !ia.isShell && ia.cmdName != "" { // We only execute the requested command if we're not requesting a // 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, ia.cmdArgs...) } logf("logging in with su %+v", loginArgs) return true, unix.Exec("/usr/bin/su", loginArgs, os.Environ()) } const ( // This controls whether we assert that our privileges were dropped // using geteuid/getegid; it's a const and not an envknob because the // incubator doesn't see the parent's environment. // // TODO(andrew): remove this const and always do this after sufficient // testing, e.g. the 1.40 release assertPrivilegesWereDropped = true // TODO(andrew-d): verify that this works in more configurations before // enabling by default. assertPrivilegesWereDroppedByAttemptingToUnDrop = false ) // dropPrivileges contains all the logic for dropping privileges to a different // UID, GID, and set of supplementary groups. This function is // security-sensitive and ordering-dependent; please be very cautious if/when // refactoring. // // WARNING: if you change this function, you *MUST* run the TestDropPrivileges // test in this package as root on at least Linux, FreeBSD and Darwin. This can // be done by running: // // go test -c ./ssh/tailssh/ && sudo ./tailssh.test -test.v -test.run TestDropPrivileges func dropPrivileges(logf logger.Logf, wantUid, wantGid int, supplementaryGroups []int) error { fatalf := func(format string, args ...any) { logf("[unexpected] error dropping privileges: "+format, args...) os.Exit(1) } euid := os.Geteuid() egid := os.Getegid() if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" { // On FreeBSD and Darwin, the first entry returned from the // getgroups(2) syscall is the egid, and changing it with // setgroups(2) changes the egid of the process. This is // technically a violation of the POSIX standard; see the // following article for more detail: // https://www.usenix.org/system/files/login/articles/325-tsafrir.pdf // // In this case, we add an entry at the beginning of the // groupIDs list containing the expected gid if it's not // already there, which modifies the egid and additional groups // as one unit. if len(supplementaryGroups) == 0 || supplementaryGroups[0] != wantGid { supplementaryGroups = append([]int{wantGid}, supplementaryGroups...) } } if err := setGroups(supplementaryGroups); err != nil { return err } if egid != wantGid { // On FreeBSD and Darwin, we may have already called the // equivalent of setegid(wantGid) via the call to setGroups, // above. However, per the manpage, setgid(getegid()) is an // allowed operation regardless of privilege level. // // FreeBSD: // The setgid() system call is permitted if the specified ID // is equal to the real group ID or the effective group ID // of the process, or if the effective user ID is that of // the super user. // // Darwin: // The setgid() function is permitted if the effective // user ID is that of the super user, or if the specified // group ID is the same as the effective group ID. If // not, but the specified group ID is the same as the real // group ID, setgid() will set the effective group ID to // the real group ID. if err := syscall.Setgid(wantGid); err != nil { fatalf("Setgid(%d): %v", wantGid, err) } } if euid != wantUid { // Switch users if required before starting the desired process. if err := syscall.Setuid(wantUid); err != nil { fatalf("Setuid(%d): %v", wantUid, err) } } // If we changed either the UID or GID, defensively assert that we // cannot reset the it back to our original values, and that the // current egid/euid are the expected values after we change // everything; if not, we exit the process. if assertPrivilegesWereDroppedByAttemptingToUnDrop { if egid != wantGid { if err := syscall.Setegid(egid); err == nil { fatalf("able to set egid back to %d", egid) } } if euid != wantUid { if err := syscall.Seteuid(euid); err == nil { fatalf("able to set euid back to %d", euid) } } } if assertPrivilegesWereDropped { if got := os.Getegid(); got != wantGid { fatalf("got egid=%d, want %d", got, wantGid) } if got := os.Geteuid(); got != wantUid { fatalf("got euid=%d, want %d", got, wantUid) } // TODO(andrew-d): assert that our supplementary groups are correct } return nil } // launchProcess launches an incubator process for the provided session. // It is responsible for configuring the process execution environment. // The caller can wait for the process to exit by calling cmd.Wait(). // // It sets ss.cmd, stdin, stdout, and stderr. func (ss *sshSession) launchProcess() error { ss.cmd = ss.newIncubatorCommand() cmd := ss.cmd homeDir := ss.conn.localUser.HomeDir if _, err := os.Stat(homeDir); err == nil { cmd.Dir = homeDir } else if os.IsNotExist(err) { // If the home directory doesn't exist, we can't chdir to it. // Instead, we'll chdir to the root directory. cmd.Dir = "/" } else { return err } cmd.Env = envForUser(ss.conn.localUser) for _, kv := range ss.Environ() { if acceptEnvPair(kv) { cmd.Env = append(cmd.Env, kv) } } ci := ss.conn.info cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_CLIENT=%s %d %d", ci.src.Addr(), ci.src.Port(), ci.dst.Port()), fmt.Sprintf("SSH_CONNECTION=%s %d %s %d", ci.src.Addr(), ci.src.Port(), ci.dst.Addr(), ci.dst.Port()), ) if ss.agentListener != nil { cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_AUTH_SOCK=%s", ss.agentListener.Addr())) } ptyReq, winCh, isPty := ss.Pty() if !isPty { ss.logf("starting non-pty command: %+v", cmd.Args) return ss.startWithStdPipes() } if sshDisablePTY() { ss.logf("pty support disabled by envknob") return errors.New("pty support disabled by envknob") } ss.ptyReq = &ptyReq pty, tty, err := ss.startWithPTY() if err != nil { return err } // We need to be able to close stdin and stdout separately later so make a // dup. ptyDup, err := syscall.Dup(int(pty.Fd())) if err != nil { pty.Close() tty.Close() return err } go resizeWindow(ptyDup /* arbitrary fd */, winCh) ss.wrStdin = pty ss.rdStdout = os.NewFile(uintptr(ptyDup), pty.Name()) ss.rdStderr = nil // not available for pty ss.childPipes = []io.Closer{tty} return nil } func resizeWindow(fd int, winCh <-chan ssh.Window) { for win := range winCh { unix.IoctlSetWinsize(fd, syscall.TIOCSWINSZ, &unix.Winsize{ Row: uint16(win.Height), Col: uint16(win.Width), }) } } // opcodeShortName is a mapping of SSH opcode // to mnemonic names expected by the termios package. // These are meant to be platform independent. var opcodeShortName = map[uint8]string{ gossh.VINTR: "intr", gossh.VQUIT: "quit", gossh.VERASE: "erase", gossh.VKILL: "kill", gossh.VEOF: "eof", gossh.VEOL: "eol", gossh.VEOL2: "eol2", gossh.VSTART: "start", gossh.VSTOP: "stop", gossh.VSUSP: "susp", gossh.VDSUSP: "dsusp", gossh.VREPRINT: "rprnt", gossh.VWERASE: "werase", gossh.VLNEXT: "lnext", gossh.VFLUSH: "flush", gossh.VSWTCH: "swtch", gossh.VSTATUS: "status", gossh.VDISCARD: "discard", gossh.IGNPAR: "ignpar", gossh.PARMRK: "parmrk", gossh.INPCK: "inpck", gossh.ISTRIP: "istrip", gossh.INLCR: "inlcr", gossh.IGNCR: "igncr", gossh.ICRNL: "icrnl", gossh.IUCLC: "iuclc", gossh.IXON: "ixon", gossh.IXANY: "ixany", gossh.IXOFF: "ixoff", gossh.IMAXBEL: "imaxbel", gossh.IUTF8: "iutf8", gossh.ISIG: "isig", gossh.ICANON: "icanon", gossh.XCASE: "xcase", gossh.ECHO: "echo", gossh.ECHOE: "echoe", gossh.ECHOK: "echok", gossh.ECHONL: "echonl", gossh.NOFLSH: "noflsh", gossh.TOSTOP: "tostop", gossh.IEXTEN: "iexten", gossh.ECHOCTL: "echoctl", gossh.ECHOKE: "echoke", gossh.PENDIN: "pendin", gossh.OPOST: "opost", gossh.OLCUC: "olcuc", gossh.ONLCR: "onlcr", gossh.OCRNL: "ocrnl", gossh.ONOCR: "onocr", gossh.ONLRET: "onlret", gossh.CS7: "cs7", gossh.CS8: "cs8", gossh.PARENB: "parenb", gossh.PARODD: "parodd", gossh.TTY_OP_ISPEED: "tty_op_ispeed", gossh.TTY_OP_OSPEED: "tty_op_ospeed", } // startWithPTY starts cmd with a pseudo-terminal attached to Stdin, Stdout and Stderr. func (ss *sshSession) startWithPTY() (ptyFile, tty *os.File, err error) { ptyReq := ss.ptyReq cmd := ss.cmd if cmd == nil { return nil, nil, errors.New("nil ss.cmd") } if ptyReq == nil { return nil, nil, errors.New("nil ss.ptyReq") } ptyFile, tty, err = pty.Open() if err != nil { err = fmt.Errorf("pty.Open: %w", err) return } defer func() { if err != nil { ptyFile.Close() tty.Close() } }() ptyRawConn, err := tty.SyscallConn() if err != nil { return nil, nil, fmt.Errorf("SyscallConn: %w", err) } var ctlErr error if err := ptyRawConn.Control(func(fd uintptr) { // Load existing PTY settings to modify them & save them back. tios, err := termios.GTTY(int(fd)) if err != nil { ctlErr = fmt.Errorf("GTTY: %w", err) return } // Set the rows & cols to those advertised from the ptyReq frame // received over SSH. tios.Row = int(ptyReq.Window.Height) tios.Col = int(ptyReq.Window.Width) for c, v := range ptyReq.Modes { if c == gossh.TTY_OP_ISPEED { tios.Ispeed = int(v) continue } if c == gossh.TTY_OP_OSPEED { tios.Ospeed = int(v) continue } k, ok := opcodeShortName[c] if !ok { ss.vlogf("unknown opcode: %d", c) continue } if _, ok := tios.CC[k]; ok { tios.CC[k] = uint8(v) continue } if _, ok := tios.Opts[k]; ok { tios.Opts[k] = v > 0 continue } ss.vlogf("unsupported opcode: %v(%d)=%v", k, c, v) } // Save PTY settings. if _, err := tios.STTY(int(fd)); err != nil { ctlErr = fmt.Errorf("STTY: %w", err) return } }); err != nil { return nil, nil, fmt.Errorf("ptyRawConn.Control: %w", err) } if ctlErr != nil { return nil, nil, fmt.Errorf("ptyRawConn.Control func: %w", ctlErr) } cmd.SysProcAttr = &syscall.SysProcAttr{ Setctty: true, Setsid: true, } updateStringInSlice(cmd.Args, "--has-tty=false", "--has-tty=true") if ptyName, err := ptyName(ptyFile); err == nil { updateStringInSlice(cmd.Args, "--tty-name=", "--tty-name="+ptyName) fullPath := filepath.Join("/dev", ptyName) cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_TTY=%s", fullPath)) } if ptyReq.Term != "" { cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term)) } cmd.Stdin = tty cmd.Stdout = tty cmd.Stderr = tty ss.logf("starting pty command: %+v", cmd.Args) if err = cmd.Start(); err != nil { return } return ptyFile, tty, nil } // startWithStdPipes starts cmd with os.Pipe for Stdin, Stdout and Stderr. func (ss *sshSession) startWithStdPipes() (err error) { var rdStdin, wrStdout, wrStderr io.ReadWriteCloser defer func() { if err != nil { closeAll(rdStdin, ss.wrStdin, ss.rdStdout, wrStdout, ss.rdStderr, wrStderr) } }() if ss.cmd == nil { return errors.New("nil cmd") } if rdStdin, ss.wrStdin, err = os.Pipe(); err != nil { return err } if ss.rdStdout, wrStdout, err = os.Pipe(); err != nil { return err } if ss.rdStderr, wrStderr, err = os.Pipe(); err != nil { return err } ss.cmd.Stdin = rdStdin ss.cmd.Stdout = wrStdout ss.cmd.Stderr = wrStderr ss.childPipes = []io.Closer{rdStdin, wrStdout, wrStderr} return ss.cmd.Start() } func envForUser(u *userMeta) []string { return []string{ fmt.Sprintf("SHELL=" + u.LoginShell()), fmt.Sprintf("USER=" + u.Username), fmt.Sprintf("HOME=" + u.HomeDir), fmt.Sprintf("PATH=" + defaultPathForUser(&u.User)), } } // updateStringInSlice mutates ss to change the first occurrence of a // to b. func updateStringInSlice(ss []string, a, b string) { for i, s := range ss { if s == a { ss[i] = b return } } } // acceptEnvPair reports whether the environment variable key=value pair // should be accepted from the client. It uses the same default as OpenSSH // AcceptEnv. func acceptEnvPair(kv string) bool { k, _, ok := strings.Cut(kv, "=") if !ok { return false } return k == "TERM" || k == "LANG" || strings.HasPrefix(k, "LC_") } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } // loginArgs returns the arguments to use to exec the login binary. func (ia *incubatorArgs) loginArgs(loginCmdPath string) []string { switch runtime.GOOS { case "darwin": args := []string{ loginCmdPath, "-f", // already authenticated // login typically discards the previous environment, but we want to // preserve any environment variables that we currently have. "-p", "-h", ia.remoteIP, // -h is "remote host" ia.localUser, } if !ia.hasTTY { args[2] = "-pq" // -q is "quiet" which suppresses the login banner } if ia.cmdName != "" { args = append(args, ia.cmdName) args = append(args, ia.cmdArgs...) } return args case "linux": if distro.Get() == distro.Arch && !fileExists("/etc/pam.d/remote") { // See https://github.com/tailscale/tailscale/issues/4924 // // Arch uses a different login binary that makes the -h flag set the PAM // service to "remote". So if they don't have that configured, don't // pass -h. return []string{loginCmdPath, "-f", ia.localUser, "-p"} } return []string{loginCmdPath, "-f", ia.localUser, "-h", ia.remoteIP, "-p"} case "freebsd", "openbsd": return []string{loginCmdPath, "-fp", "-h", ia.remoteIP, ia.localUser} } panic("unimplemented") } func setGroups(groupIDs []int) error { if runtime.GOOS == "darwin" && len(groupIDs) > 16 { // darwin returns "invalid argument" if more than 16 groups are passed to syscall.Setgroups // some info can be found here: // https://opensource.apple.com/source/samba/samba-187.8/patches/support-darwin-initgroups-syscall.auto.html // this fix isn't great, as anyone reading this has probably just wasted hours figuring out why // some permissions thing isn't working, due to some arbitrary group ordering, but it at least allows // this to work for more things than it previously did. groupIDs = groupIDs[:16] } err := syscall.Setgroups(groupIDs) if err != nil && os.Geteuid() != 0 && groupsMatchCurrent(groupIDs) { // If we're not root, ignore a Setgroups failure if all groups are the same. return nil } return err } func groupsMatchCurrent(groupIDs []int) bool { existing, err := syscall.Getgroups() if err != nil { return false } if len(existing) != len(groupIDs) { return false } groupIDs = slices.Clone(groupIDs) sort.Ints(groupIDs) sort.Ints(existing) return slices.Equal(groupIDs, existing) }