tailscale/ssh/tailssh/incubator.go

841 lines
25 KiB
Go

// 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 (
"errors"
"flag"
"fmt"
"io"
"log"
"log/syslog"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"sync/atomic"
"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 debugTest.Load() {
incubatorArgs = append(incubatorArgs, "--debug-test")
}
if isSFTP {
incubatorArgs = append(incubatorArgs, "--sftp")
} else {
if isShell {
incubatorArgs = append(incubatorArgs, "--shell")
}
// Only the macOS version of the login command supports executing a
// command, all other versions only support launching a shell
// without taking any arguments.
shouldUseLoginCmd := isShell || runtime.GOOS == "darwin"
if hostinfo.IsSELinuxEnforcing() {
// If we're running on a SELinux-enabled system, the login
// command will be unable to set the correct context for the
// shell. Fall back to using the incubator to launch the shell.
// See http://github.com/tailscale/tailscale/issues/4908.
shouldUseLoginCmd = false
}
if shouldUseLoginCmd {
if lp, err := exec.LookPath("login"); err == nil {
incubatorArgs = append(incubatorArgs, "--login-cmd="+lp)
}
}
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...)
}
var debugIncubator bool
var debugTest atomic.Bool
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
loginCmdPath string
cmdArgs []string
debugTest bool
}
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.StringVar(&a.loginCmdPath, "login-cmd", "", "the path to `login` cmd")
flags.BoolVar(&a.debugTest, "debug-test", false, "should debug in test mode")
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
}
} else if ia.debugTest {
// In testing, we don't always have syslog, log to a temp file
if logFile, err := os.OpenFile("/tmp/tailscalessh.log", os.O_APPEND|os.O_WRONLY, 0666); err == nil {
lf := log.New(logFile, "", 0)
logf = func(msg string, args ...any) {
lf.Printf(msg, args...)
logFile.Sync()
}
defer logFile.Close()
}
}
euid := os.Geteuid()
runningAsRoot := euid == 0
if runningAsRoot && ia.loginCmdPath != "" {
// Check if we can exec into the login command instead of trying to
// incubate ourselves.
if la := ia.loginArgs(); la != nil {
return unix.Exec(ia.loginCmdPath, la, os.Environ())
}
}
// 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
}
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.
// It returns nil if the login binary should not be used.
// The login binary is only used:
// - on darwin, if the client is requesting a shell or a command.
// - on linux and BSD, if the client is requesting a shell with a TTY.
func (ia *incubatorArgs) loginArgs() []string {
if ia.isSFTP {
return nil
}
switch runtime.GOOS {
case "darwin":
args := []string{
ia.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 !ia.isShell || !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.
return nil
}
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{ia.loginCmdPath, "-f", ia.localUser, "-p"}
}
return []string{ia.loginCmdPath, "-f", ia.localUser, "-h", ia.remoteIP, "-p"}
case "freebsd", "openbsd":
if !ia.isShell || !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.
return nil
}
return []string{ia.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)
}