mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 13:05:46 +00:00
61886e031e
There are two race conditions in output handling. The first race condition is due to a misuse of exec.Cmd.StdoutPipe. The documentation explicitly forbids concurrent use of StdoutPipe with exec.Cmd.Wait (see golang/go#60908) because Wait will close both sides of the pipe once the process ends without any guarantees that all data has been read from the pipe. To fix this, we allocate the os.Pipes ourselves and manage cleanup ourselves when the process has ended. The second race condition is because sshSession.run waits upon exec.Cmd to finish and then immediately proceeds to call ss.Exit, which will close all output streams going to the SSH client. This may interrupt any asynchronous io.Copy still copying data. To fix this, we close the write-side of the os.Pipes after the process has finished (and before calling ss.Exit) and synchronously wait for the io.Copy routines to finish. Fixes #7601 Signed-off-by: Joe Tsai <joetsai@digital-static.net> Co-authored-by: Maisem Ali <maisem@tailscale.com>
812 lines
24 KiB
Go
812 lines
24 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"
|
|
"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/exp/slices"
|
|
"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, ",")
|
|
}
|
|
|
|
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")
|
|
}
|
|
// 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...)
|
|
}
|
|
|
|
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
|
|
loginCmdPath string
|
|
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.StringVar(&a.loginCmdPath, "login-cmd", "", "the path to `login` cmd")
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
return server.Serve()
|
|
}
|
|
|
|
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()
|
|
}
|
|
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)
|
|
}
|