mirror of
https://github.com/tailscale/tailscale.git
synced 2026-11-09 02:53:09 -05:00
Fixes #22158 Change-Id: I238244c34c96c444c258b9e006c0084f4aa8db27 Signed-off-by: Philip Taron <philip.taron@gmail.com>
1464 lines
47 KiB
Go
1464 lines
47 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
||
// 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 && !android) || (darwin && !ios) || freebsd || openbsd
|
||
|
||
package tailssh
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"log"
|
||
"log/syslog"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"slices"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync/atomic"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/creack/pty"
|
||
gliderssh "github.com/tailscale/gliderssh"
|
||
"github.com/u-root/u-root/pkg/termios"
|
||
"golang.org/x/crypto/ssh"
|
||
"golang.org/x/sys/unix"
|
||
"tailscale.com/cmd/tailscaled/childproc"
|
||
"tailscale.com/hostinfo"
|
||
"tailscale.com/tailcfg/nodecap"
|
||
"tailscale.com/types/logger"
|
||
"tailscale.com/version/distro"
|
||
)
|
||
|
||
const (
|
||
linux = "linux"
|
||
darwin = "darwin"
|
||
freebsd = "freebsd"
|
||
openbsd = "openbsd"
|
||
windows = "windows"
|
||
)
|
||
|
||
// registerIncubator registers the incubator child process handlers and
|
||
// the per-connection ssh monitor.
|
||
// It is called from [Register], which is called from the init of
|
||
// tailscale.com/feature/ssh.
|
||
func registerIncubator() {
|
||
childproc.Add("ssh", beIncubator)
|
||
childproc.Add("sftp", beSFTP)
|
||
childproc.Add("ssh-monitor", beMonitor)
|
||
childproc.Add("ssh-fwd", beFwdChild)
|
||
}
|
||
|
||
var ptyName = func(f *os.File) (string, error) {
|
||
return "", fmt.Errorf("unimplemented")
|
||
}
|
||
|
||
// maybeStartLoginSession informs the system that we are about to log someone
|
||
// in. On success, it may return a non-nil close func which must be closed to
|
||
// release the session.
|
||
// 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.
|
||
// See maybeStartLoginSessionLinux.
|
||
var maybeStartLoginSession = func(dlogf logger.Logf, ia incubatorArgs) (close func() error) {
|
||
return nil
|
||
}
|
||
|
||
// truePaths are the common locations to find the true binary, in likelihood order.
|
||
var truePaths = [...]string{"/usr/bin/true", "/bin/true"}
|
||
|
||
// tryExecInDir tries to run a command in dir and returns nil if it succeeds.
|
||
// Otherwise, it returns a filesystem error or a timeout error if the command
|
||
// took too long.
|
||
func tryExecInDir(ctx context.Context, dir string) error {
|
||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||
defer cancel()
|
||
|
||
run := func(path string) error {
|
||
cmd := exec.CommandContext(ctx, path)
|
||
cmd.Dir = dir
|
||
// The probe runs in the daemon's environment. Strip the systemd
|
||
// service-instance variables; see
|
||
// hostinfo.EnvironWithoutSystemdServiceVars.
|
||
cmd.Env = hostinfo.EnvironWithoutSystemdServiceVars(nil)
|
||
return cmd.Run()
|
||
}
|
||
|
||
// Assume that the following executables exist, are executable, and
|
||
// immediately return.
|
||
if runtime.GOOS == windows {
|
||
windir := os.Getenv("windir")
|
||
return run(filepath.Join(windir, "system32", "doskey.exe"))
|
||
}
|
||
// Execute the first "true" we find in the list.
|
||
for _, path := range truePaths {
|
||
// Note: LookPath does not consult $PATH when passed multi-label paths.
|
||
if p, err := exec.LookPath(path); err == nil {
|
||
return run(p)
|
||
}
|
||
}
|
||
return exec.ErrNotFound
|
||
}
|
||
|
||
// directExecAllowed reports whether the direct-exec fallback (used when
|
||
// ss.conn.srv.tailscaledPath is empty) may run a session for lu as the given
|
||
// effective uid. Unlike the incubator path there is no privilege drop, so a
|
||
// root tailscaled must refuse to run a non-root user's session as root,
|
||
// mirroring canSwitchToLocalUser's semantics (a non-root process can only
|
||
// run a session as itself, which run() enforces).
|
||
func directExecAllowed(euid int, lu *userMeta) bool {
|
||
if euid != 0 {
|
||
return true
|
||
}
|
||
return isRootUser(lu)
|
||
}
|
||
|
||
// newIncubatorCommand returns a new exec.Cmd configured with
|
||
// `tailscaled be-child ssh` as the entrypoint.
|
||
//
|
||
// If ss.srv.tailscaledPath is empty, this method is almost equivalent to
|
||
// exec.CommandContext. It will refuse to run in SFTP-mode. It will simulate the
|
||
// behavior of SSHD when by falling back to the root directory if it cannot run
|
||
// a command in the user’s home directory.
|
||
//
|
||
// It also returns forwardedEnv, the set of client-forwarded "KEY=VALUE"
|
||
// environment variables accepted by the acceptEnv policy. These may contain
|
||
// secrets, so the caller passes them to the child via an inherited file rather
|
||
// than the command line or environment, where they could leak, or influence
|
||
// the privileged child.
|
||
//
|
||
// The returned Cmd.Env is guaranteed to be nil; the caller must populate it.
|
||
func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, forwardedEnv []string, err error) {
|
||
defer func() {
|
||
if cmd != nil && cmd.Env != nil {
|
||
panic("internal error")
|
||
}
|
||
}()
|
||
|
||
var isSFTP, isShell bool
|
||
switch ss.Subsystem() {
|
||
case "sftp":
|
||
isSFTP = true
|
||
case "":
|
||
isShell = ss.RawCommand() == ""
|
||
default:
|
||
panic(fmt.Sprintf("unexpected subsystem: %v", ss.Subsystem()))
|
||
}
|
||
|
||
if ss.conn.srv.tailscaledPath == "" {
|
||
if isSFTP {
|
||
// SFTP relies on the embedded Go-based SFTP server in tailscaled,
|
||
// so without tailscaled, we can't serve SFTP.
|
||
return nil, nil, errors.New("no tailscaled found on path, can't serve SFTP")
|
||
}
|
||
|
||
// Without tailscaled there is no be-child incubator to drop
|
||
// privileges, so this fallback runs the session as the current
|
||
// user. A root tailscaled must refuse to run a non-root user's
|
||
// session as root, mirroring canSwitchToLocalUser's semantics.
|
||
if !directExecAllowed(os.Geteuid(), ss.conn.localUser) {
|
||
return nil, nil, fmt.Errorf("refusing to run session for local user %q as root without tailscaled", ss.conn.localUser.Username)
|
||
}
|
||
|
||
loginShell := ss.conn.localUser.LoginShell()
|
||
args := shellArgs(isShell, ss.RawCommand())
|
||
logf("directly running %s %q", loginShell, args)
|
||
cmd = exec.CommandContext(ss.ctx, loginShell, args...)
|
||
|
||
// While running directly instead of using `tailscaled be-child`,
|
||
// do what sshd does by running inside the home directory,
|
||
// falling back to the root directory it doesn't have permissions.
|
||
// This can happen if the system has networked home directories,
|
||
// i.e. NFS or SMB, which enable root-squashing by default.
|
||
cmd.Dir = ss.conn.localUser.HomeDir
|
||
err := tryExecInDir(ss.ctx, cmd.Dir)
|
||
switch {
|
||
case errors.Is(err, exec.ErrNotFound):
|
||
// /bin/true might not be installed on a barebones system,
|
||
// so we assume that the home directory does not exist.
|
||
cmd.Dir = "/"
|
||
case errors.Is(err, fs.ErrPermission) || errors.Is(err, fs.ErrNotExist):
|
||
// Ensure that cmd.Dir is the source of the error.
|
||
if pathErr, ok := errors.AsType[*fs.PathError](err); ok && pathErr.Path == cmd.Dir {
|
||
// If we cannot run loginShell in localUser.HomeDir,
|
||
// we will try to run this command in the root directory.
|
||
cmd.Dir = "/"
|
||
} else {
|
||
return nil, nil, err
|
||
}
|
||
case err != nil:
|
||
return nil, nil, err
|
||
}
|
||
|
||
return cmd, nil, nil
|
||
}
|
||
|
||
lu := ss.conn.localUser
|
||
ci := ss.conn.info
|
||
groups := strings.Join(ss.conn.userGroupIDs, ",")
|
||
remoteUser := ci.uprof.LoginName
|
||
if ci.node.IsTagged() {
|
||
remoteUser = strings.Join(ci.node.Tags().AsSlice(), ",")
|
||
}
|
||
|
||
// incubatorArgs is the argv after the executable path and the
|
||
// incubatorSpawnPrefix. It holds the identity flags, the session
|
||
// flags, and the command. Tests that run a real session through the
|
||
// monitor override incubatorSpawnPrefix to route to beIncubator
|
||
// directly.
|
||
incubatorArgs := []string{
|
||
"--login-shell=" + lu.LoginShell(),
|
||
"--uid=" + lu.Uid,
|
||
"--gid=" + lu.Gid,
|
||
"--groups=" + groups,
|
||
"--local-user=" + lu.Username,
|
||
"--home-dir=" + lu.HomeDir,
|
||
"--remote-user=" + remoteUser,
|
||
"--remote-ip=" + ci.src.Addr().String(),
|
||
"--has-tty=false", // updated in-place by startWithPTY
|
||
"--tty-name=", // updated in-place by startWithPTY
|
||
}
|
||
|
||
// We have to check the below outside of the incubator process, because it
|
||
// relies on the "getenforce" command being on the PATH, which it is not
|
||
// when in the incubator.
|
||
if runtime.GOOS == linux && hostinfo.IsSELinuxEnforcing() {
|
||
incubatorArgs = append(incubatorArgs, "--is-selinux-enforcing")
|
||
}
|
||
|
||
nm := ss.conn.srv.lb.NetMapNoPeers()
|
||
forceV1Behavior := nm.HasCap(nodecap.SSHBehaviorV1) && !nm.HasCap(nodecap.SSHBehaviorV2)
|
||
if forceV1Behavior {
|
||
incubatorArgs = append(incubatorArgs, "--force-v1-behavior")
|
||
}
|
||
|
||
if debugTest.Load() {
|
||
incubatorArgs = append(incubatorArgs, "--debug-test")
|
||
}
|
||
|
||
switch {
|
||
case isSFTP:
|
||
// Note that we include both the `--sftp` flag and a command to launch
|
||
// tailscaled as `be-child sftp`. If login or su is available, and
|
||
// we're not running with tailcfg.NodeAttrSSHBehaviorV1, this will
|
||
// result in serving SFTP within a login shell, with full PAM
|
||
// integration. Otherwise, we'll serve SFTP in the incubator process
|
||
// with no PAM integration.
|
||
incubatorArgs = append(incubatorArgs, "--sftp", fmt.Sprintf("--cmd=%s be-child sftp", ss.conn.srv.tailscaledPath))
|
||
case isShell:
|
||
incubatorArgs = append(incubatorArgs, "--shell")
|
||
default:
|
||
incubatorArgs = append(incubatorArgs, "--cmd="+ss.RawCommand())
|
||
}
|
||
|
||
allowSendEnv := nm.HasCap(nodecap.SSHEnvironmentVariables)
|
||
if allowSendEnv {
|
||
env, err := filterEnv(ss.conn.acceptEnv, ss.Session.Environ())
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
if len(env) > 0 {
|
||
// The accepted environment may contain secrets, so it is communicated through a fd
|
||
// created by the caller, never the argv or the child's environment.
|
||
incubatorArgs = append(incubatorArgs, fmt.Sprintf("--env-fd=%d", forwardedEnvChildFD))
|
||
forwardedEnv = env
|
||
}
|
||
}
|
||
|
||
argv := append(slices.Clone(incubatorSpawnPrefix), incubatorArgs...)
|
||
cmd = exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, argv...)
|
||
// The incubator will chdir into the home directory after it drops privileges.
|
||
cmd.Dir = "/"
|
||
return cmd, forwardedEnv, nil
|
||
}
|
||
|
||
var (
|
||
debugIncubator bool
|
||
debugTest atomic.Bool
|
||
)
|
||
|
||
// incubatorSpawnPrefix is the argv prefix, after the executable path,
|
||
// used to launch the incubator. In tailscaled it is "be-child ssh",
|
||
// dispatched by cmd/tailscaled's beChild through childproc. Tests that
|
||
// drive a real session through the monitor override this seam to
|
||
// re-exec the test binary and dispatch to beIncubator via the helper
|
||
// test.
|
||
var incubatorSpawnPrefix = []string{"be-child", "ssh"}
|
||
|
||
type incubatorArgs struct {
|
||
loginShell string
|
||
uid int
|
||
gid int
|
||
gids []int
|
||
localUser string
|
||
homeDir string
|
||
remoteUser string
|
||
remoteIP string
|
||
ttyName string
|
||
hasTTY bool
|
||
cmd string
|
||
isSFTP bool
|
||
isShell bool
|
||
forceV1Behavior bool
|
||
debugTest bool
|
||
isSELinuxEnforcing bool
|
||
// Deprecated: encodedEnv is deprecated and must not be used by new code.
|
||
// It is parsed only so this child keeps working when exec'd by an
|
||
// outdated parent tailscaled that still passes it.
|
||
encodedEnv string
|
||
// envFD is the file descriptor to read the forwarded environment from
|
||
// (a JSON array of KEY=VALUE pairs), or -1 if none.
|
||
envFD int
|
||
// forwardedEnv holds the pairs loaded by loadForwardedEnv.
|
||
forwardedEnv []string
|
||
}
|
||
|
||
func parseIncubatorArgs(args []string) (incubatorArgs, error) {
|
||
var ia incubatorArgs
|
||
var groups string
|
||
|
||
flags := flag.NewFlagSet("", flag.ExitOnError)
|
||
flags.StringVar(&ia.loginShell, "login-shell", "", "path to the user's preferred login shell")
|
||
flags.IntVar(&ia.uid, "uid", 0, "the uid of local-user")
|
||
flags.IntVar(&ia.gid, "gid", 0, "the gid of local-user")
|
||
flags.StringVar(&groups, "groups", "", "comma-separated list of gids of local-user")
|
||
flags.StringVar(&ia.localUser, "local-user", "", "the user to run as")
|
||
flags.StringVar(&ia.homeDir, "home-dir", "/", "the user's home directory")
|
||
flags.StringVar(&ia.remoteUser, "remote-user", "", "the remote user/tags")
|
||
flags.StringVar(&ia.remoteIP, "remote-ip", "", "the remote Tailscale IP")
|
||
flags.StringVar(&ia.ttyName, "tty-name", "", "the tty name (pts/3)")
|
||
flags.BoolVar(&ia.hasTTY, "has-tty", false, "is the output attached to a tty")
|
||
flags.StringVar(&ia.cmd, "cmd", "", "the cmd to launch, including all arguments (ignored in sftp mode)")
|
||
flags.BoolVar(&ia.isShell, "shell", false, "is launching a shell (with no cmds)")
|
||
flags.BoolVar(&ia.isSFTP, "sftp", false, "run sftp server (cmd is ignored)")
|
||
flags.BoolVar(&ia.forceV1Behavior, "force-v1-behavior", false, "allow falling back to the su command if login is unavailable")
|
||
flags.BoolVar(&ia.debugTest, "debug-test", false, "should debug in test mode")
|
||
flags.BoolVar(&ia.isSELinuxEnforcing, "is-selinux-enforcing", false, "whether SELinux is in enforcing mode")
|
||
// DEPRECATED: retained for version-skew compatibility only. DO NOT USE.
|
||
flags.StringVar(&ia.encodedEnv, "encoded-env", "", "deprecated; do not use")
|
||
flags.IntVar(&ia.envFD, "env-fd", -1, "file descriptor to read the forwarded environment from (JSON array of KEY=VALUE pairs)")
|
||
flags.Parse(args)
|
||
|
||
// A user without supplementary groups gets an empty --groups=, which is
|
||
// no groups rather than a single empty group ID.
|
||
if groups != "" {
|
||
for g := range strings.SplitSeq(groups, ",") {
|
||
gid, err := strconv.Atoi(g)
|
||
if err != nil {
|
||
return ia, fmt.Errorf("unable to parse group id %q: %w", g, err)
|
||
}
|
||
ia.gids = append(ia.gids, gid)
|
||
}
|
||
}
|
||
|
||
// envFD comes from an ExtraFiles entry, so it must never name stdin/out/err
|
||
if ia.envFD >= 0 && ia.envFD < 3 {
|
||
return ia, fmt.Errorf("invalid --env-fd %d: must be >= 3", ia.envFD)
|
||
}
|
||
|
||
return ia, nil
|
||
}
|
||
|
||
// loadForwardedEnv reads the client-forwarded environment pairs into ia.forwardedEnv, from the
|
||
// inherited file named by --env-fd. The pairs only enter the su/login/shell environment,
|
||
// never this process's own environment.
|
||
func (ia *incubatorArgs) loadForwardedEnv() error {
|
||
var pairs []string
|
||
switch {
|
||
case ia.envFD >= 0:
|
||
if ia.envFD < 3 {
|
||
return fmt.Errorf("invalid --env-fd=%d: must be >= 3", ia.envFD)
|
||
}
|
||
f := os.NewFile(uintptr(ia.envFD), "forwarded-env")
|
||
defer f.Close()
|
||
if err := json.NewDecoder(f).Decode(&pairs); err != nil {
|
||
return fmt.Errorf("unable to read forwarded environment: %w", err)
|
||
}
|
||
case ia.encodedEnv != "": // Legacy path to support an outdated parent tailscaled
|
||
if unquoted, err := strconv.Unquote(ia.encodedEnv); err != nil {
|
||
return fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||
} else if err := json.Unmarshal([]byte(unquoted), &pairs); err != nil {
|
||
return fmt.Errorf("unable to parse encodedEnv %q: %w", ia.encodedEnv, err)
|
||
}
|
||
}
|
||
// Enforce "su -w" integrity and the dangerous-env-var denylist
|
||
// child-side: old parents may not have filtered these.
|
||
pairs = slices.DeleteFunc(pairs, func(kv string) bool {
|
||
k, _, ok := strings.Cut(kv, "=")
|
||
return !ok || isDangerousEnvVar(k) || forbiddenEnvKey(k) || strings.Contains(kv, "\x00")
|
||
})
|
||
ia.forwardedEnv = pairs
|
||
return nil
|
||
}
|
||
|
||
// forwardedEnviron appends accepted client values to the filtered base environment.
|
||
func (ia *incubatorArgs) forwardedEnviron() []string {
|
||
return append(ia.rootExecEnviron(), ia.forwardedEnv...)
|
||
}
|
||
|
||
// rootExecEnviron copies the incubator environment without TERM or locale
|
||
// variables. login and su receive forwardedEnv in addition to this base;
|
||
// the capability probe receives only the base.
|
||
func (ia *incubatorArgs) rootExecEnviron() []string {
|
||
env := make([]string, 0, len(os.Environ()))
|
||
for _, kv := range os.Environ() {
|
||
if k, _, ok := strings.Cut(kv, "="); ok && clientDerivedEnvKey(k) {
|
||
continue
|
||
}
|
||
env = append(env, kv)
|
||
}
|
||
return env
|
||
}
|
||
|
||
// clientDerivedEnvKey identifies the default client environment variables
|
||
// accepted by incubatorEnv, including TERM from a PTY request.
|
||
func clientDerivedEnvKey(k string) bool {
|
||
return k == "TERM" || k == "LANG" || strings.HasPrefix(k, "LC_")
|
||
}
|
||
|
||
// suAllowEnvKeys returns the "su -w" allowlist for the given args:
|
||
// SSH_AUTH_SOCK plus the names of the policy-forwarded pairs.
|
||
func (ia *incubatorArgs) suAllowEnvKeys() []string {
|
||
keys := make([]string, 0, 1+len(ia.forwardedEnv))
|
||
keys = append(keys, "SSH_AUTH_SOCK")
|
||
for _, kv := range ia.forwardedEnv {
|
||
if k, _, ok := strings.Cut(kv, "="); ok {
|
||
keys = append(keys, k)
|
||
}
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// 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.
|
||
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, err := parseIncubatorArgs(args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ia.encodedEnv != "" {
|
||
log.Printf("WARNING: tailscaled be-child: accepted SSH environment variables were passed via the deprecated --encoded-env flag; " +
|
||
"the running tailscaled is outdated. Update tailscaled to the latest version and restart for the latest security fixes.")
|
||
}
|
||
if err := ia.loadForwardedEnv(); err != nil {
|
||
return err
|
||
}
|
||
if ia.isSFTP && ia.isShell {
|
||
return fmt.Errorf("--sftp and --shell are mutually exclusive")
|
||
}
|
||
|
||
dlogf := 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 {
|
||
dlogf = log.New(sl, "", 0).Printf
|
||
}
|
||
} else if ia.debugTest {
|
||
// In testing, we don't always have syslog, so log to a temp file.
|
||
if logFile, err := os.OpenFile("/tmp/tailscalessh.log", os.O_APPEND|os.O_WRONLY, 0o666); err == nil {
|
||
lf := log.New(logFile, "", 0)
|
||
dlogf = func(msg string, args ...any) {
|
||
lf.Printf(msg, args...)
|
||
logFile.Sync()
|
||
}
|
||
defer logFile.Close()
|
||
}
|
||
}
|
||
|
||
if !shouldAttemptLoginShell(dlogf, ia) {
|
||
dlogf("not attempting login shell")
|
||
return handleInProcess(dlogf, ia)
|
||
}
|
||
|
||
// First try the login command
|
||
if err := tryExecLogin(dlogf, ia); err != nil {
|
||
return err
|
||
}
|
||
|
||
// If we got here, we weren't able to use login (because tryExecLogin
|
||
// returned without replacing the running process), maybe we can use
|
||
// su.
|
||
if handled, err := trySU(dlogf, ia); handled {
|
||
return err
|
||
} else {
|
||
dlogf("not attempting su")
|
||
return handleInProcess(dlogf, ia)
|
||
}
|
||
}
|
||
|
||
func handleInProcess(dlogf logger.Logf, ia incubatorArgs) error {
|
||
if ia.isSFTP {
|
||
return handleSFTPInProcess(dlogf, ia)
|
||
}
|
||
return handleSSHInProcess(dlogf, ia)
|
||
}
|
||
|
||
func handleSFTPInProcess(dlogf logger.Logf, ia incubatorArgs) error {
|
||
dlogf("handling sftp")
|
||
|
||
sessionCloser := maybeStartLoginSession(dlogf, ia)
|
||
if sessionCloser != nil {
|
||
defer sessionCloser()
|
||
}
|
||
|
||
if err := dropPrivileges(dlogf, ia); err != nil {
|
||
return err
|
||
}
|
||
|
||
return serveSFTP()
|
||
}
|
||
|
||
// shouldAttemptLoginShell decides whether we should attempt to get a full
|
||
// login shell with the login or su commands. We will attempt a login shell
|
||
// if all of the following conditions are met.
|
||
//
|
||
// - We are running as root
|
||
// - This is not an SELinuxEnforcing host
|
||
//
|
||
// The last condition exists because 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.
|
||
func shouldAttemptLoginShell(dlogf logger.Logf, ia incubatorArgs) bool {
|
||
if ia.forceV1Behavior && ia.isSFTP {
|
||
// v1 behavior did not run SFTP within a login shell.
|
||
dlogf("Forcing v1 behavior, won't use login shell for SFTP")
|
||
return false
|
||
}
|
||
|
||
return runningAsRoot() && !ia.isSELinuxEnforcing
|
||
}
|
||
|
||
func runningAsRoot() bool {
|
||
euid := os.Geteuid()
|
||
return euid == 0
|
||
}
|
||
|
||
// tryExecLogin attempts to handle the ssh session by creating a full login
|
||
// shell using the login command. If it never tried, it returns nil. If it
|
||
// failed to do so, it returns an error.
|
||
//
|
||
// 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, tryExecLogin returns (false, nil) to indicate that processing
|
||
// should fall through to other methods, such as using the su command.
|
||
//
|
||
// Note that this uses unix.Exec to replace the current process, so in cases
|
||
// where we actually do run login, no subsequent Go code will execute.
|
||
func tryExecLogin(dlogf logger.Logf, ia incubatorArgs) 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 {
|
||
dlogf("won't use login because we're not in a shell or on macOS")
|
||
return nil
|
||
}
|
||
|
||
switch runtime.GOOS {
|
||
case linux, freebsd, openbsd:
|
||
if !ia.hasTTY {
|
||
dlogf("can't use login because of missing TTY")
|
||
// 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 nil
|
||
}
|
||
}
|
||
|
||
loginCmdPath, err := exec.LookPath("login")
|
||
if err != nil {
|
||
dlogf("failed to get login args: %s", err)
|
||
return nil
|
||
}
|
||
loginArgs := ia.loginArgs(loginCmdPath)
|
||
dlogf("logging in with %+v", loginArgs)
|
||
|
||
environ := append(ia.rootExecEnviron(), ia.forwardedEnv...)
|
||
|
||
// If Exec works, the Go code will not proceed past this:
|
||
err = unix.Exec(loginCmdPath, loginArgs, environ)
|
||
|
||
// If we made it here, Exec failed.
|
||
return err
|
||
}
|
||
|
||
// trySU 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 trySU(dlogf logger.Logf, ia incubatorArgs) (handled bool, err error) {
|
||
if ia.forceV1Behavior {
|
||
// v1 behavior did not use su.
|
||
dlogf("Forcing v1 behavior, won't use su")
|
||
return false, nil
|
||
}
|
||
|
||
su := findSU(dlogf, ia)
|
||
if su == "" {
|
||
return false, nil
|
||
}
|
||
|
||
sessionCloser := maybeStartLoginSession(dlogf, ia)
|
||
if sessionCloser != nil {
|
||
defer sessionCloser()
|
||
}
|
||
|
||
// Pass forwarded pairs in su's environment and allow -w to copy
|
||
// them into the login shell's environment.
|
||
environ := append(ia.rootExecEnviron(), ia.forwardedEnv...)
|
||
allowListEnvKeys := ia.suAllowEnvKeys()
|
||
|
||
loginArgs := []string{
|
||
su,
|
||
"-w", strings.Join(allowListEnvKeys, ","),
|
||
"-l",
|
||
ia.localUser,
|
||
}
|
||
if ia.cmd != "" {
|
||
// Note - unlike the login command, su allows using both -l and -c.
|
||
loginArgs = append(loginArgs, "-c", ia.cmd)
|
||
}
|
||
|
||
dlogf("logging in with %+v", loginArgs)
|
||
|
||
// If Exec works, the Go code will not proceed past this:
|
||
err = unix.Exec(su, loginArgs, environ)
|
||
|
||
// If we made it here, Exec failed.
|
||
return true, err
|
||
}
|
||
|
||
// findSU attempts to find an su command which supports the -l and -c flags.
|
||
// This actually calls the su command, which can cause side effects like
|
||
// triggering pam_mkhomedir. If a suitable su is not available, this returns
|
||
// "".
|
||
func findSU(dlogf logger.Logf, ia incubatorArgs) string {
|
||
// Currently, we only support falling back to su on Linux. This
|
||
// potentially could work on BSDs as well, but requires testing.
|
||
if runtime.GOOS != linux {
|
||
return ""
|
||
}
|
||
|
||
// gokrazy doesn't include su. And, if someone installs a breakglass/
|
||
// debugging package on gokrazy, we don't want to use its su.
|
||
if distro.Get() == distro.Gokrazy {
|
||
return ""
|
||
}
|
||
|
||
su, err := exec.LookPath("su")
|
||
if err != nil {
|
||
dlogf("can't find su command: %v", err)
|
||
return ""
|
||
}
|
||
|
||
// First try to execute su -w <allow listed env> -l <user> -c true
|
||
// to make sure su supports the necessary arguments.
|
||
if err := suProbeCmd(ia, su).Run(); err != nil {
|
||
dlogf("su check failed: %s", err)
|
||
return ""
|
||
}
|
||
|
||
return su
|
||
}
|
||
|
||
// suProbeCmd builds findSU's capability probe command. The probe runs
|
||
// su with rootExecEnviron.
|
||
func suProbeCmd(ia incubatorArgs, su string) *exec.Cmd {
|
||
cmd := exec.Command(
|
||
su,
|
||
"-w", strings.Join(ia.suAllowEnvKeys(), ","),
|
||
"-l",
|
||
ia.localUser,
|
||
"-c", "true",
|
||
)
|
||
cmd.Env = ia.rootExecEnviron()
|
||
return cmd
|
||
}
|
||
|
||
// handleSSHInProcess 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` in the user's
|
||
// login shell.
|
||
func handleSSHInProcess(dlogf logger.Logf, ia incubatorArgs) error {
|
||
sessionCloser := maybeStartLoginSession(dlogf, ia)
|
||
if sessionCloser != nil {
|
||
defer sessionCloser()
|
||
}
|
||
|
||
if err := dropPrivileges(dlogf, ia); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Build the shell environment after dropping privileges.
|
||
environ := ia.forwardedEnviron()
|
||
|
||
args := shellArgs(ia.isShell, ia.cmd)
|
||
dlogf("running %s %q", ia.loginShell, args)
|
||
cmd := newCommand(ia.hasTTY, ia.loginShell, environ, args)
|
||
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
|
||
}
|
||
|
||
func newCommand(hasTTY bool, cmdPath string, cmdEnviron []string, cmdArgs []string) *exec.Cmd {
|
||
cmd := exec.Command(cmdPath, cmdArgs...)
|
||
cmd.Stdin = os.Stdin
|
||
cmd.Stdout = os.Stdout
|
||
cmd.Stderr = os.Stderr
|
||
cmd.Env = cmdEnviron
|
||
|
||
if 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,
|
||
}
|
||
}
|
||
|
||
return cmd
|
||
}
|
||
|
||
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 calls doDropPrivileges with uid, gid, and gids from the given
|
||
// incubatorArgs.
|
||
func dropPrivileges(dlogf logger.Logf, ia incubatorArgs) error {
|
||
return doDropPrivileges(dlogf, ia.uid, ia.gid, ia.gids, ia.homeDir)
|
||
}
|
||
|
||
// doDropPrivileges 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 TestDoDropPrivileges
|
||
// 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 TestDoDropPrivileges
|
||
func doDropPrivileges(dlogf logger.Logf, wantUid, wantGid int, supplementaryGroups []int, homeDir string) error {
|
||
dlogf("dropping privileges")
|
||
fatalf := func(format string, args ...any) {
|
||
dlogf("[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
|
||
}
|
||
|
||
// Prefer to run in user's homedir if possible. We ignore a failure to Chdir,
|
||
// which just leaves us at "/" where we launched in the first place.
|
||
dlogf("attempting to chdir to user's home directory %q", homeDir)
|
||
if err := os.Chdir(homeDir); err != nil {
|
||
dlogf("failed to chdir to user's home directory %q, continuing in current directory", homeDir)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// incubatorEnv builds the environment (cmd.Env) for the incubator child:
|
||
// the user's login environment, the client's TERM/LANG/LC_* (matching
|
||
// OpenSSH's default AcceptEnv), the connection metadata, and the optional
|
||
// agent socket. acceptEnv-forwarded variables are not included; they travel
|
||
// via an inherited file instead (see forwardedEnvFile).
|
||
func (ss *sshSession) incubatorEnv() []string {
|
||
env := envForUser(ss.conn.localUser)
|
||
for _, kv := range ss.Environ() {
|
||
if acceptEnvPair(kv) {
|
||
env = append(env, kv)
|
||
}
|
||
}
|
||
// The pty request's TERM passes the same acceptEnvPair value rule as
|
||
// the environ-sourced TERM. It is assembled here, with the rest of
|
||
// the client-derived environment, so the client-derived pairs can be
|
||
// forwarded to the incubator through one channel; see launchProcess.
|
||
if ptyReq, _, isPty := ss.Pty(); isPty && ptyReq.Term != "" && acceptEnvPair("TERM="+ptyReq.Term) {
|
||
env = append(env, "TERM="+ptyReq.Term)
|
||
}
|
||
|
||
ci := ss.conn.info
|
||
env = append(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 {
|
||
env = append(env, fmt.Sprintf("SSH_AUTH_SOCK=%s", ss.agentListener.Addr()))
|
||
}
|
||
|
||
return env
|
||
}
|
||
|
||
// mergeForwardedEnv adds default TERM and locale values not already present
|
||
// in forwarded. Defaults use last-value-wins, as exec.Cmd.Env does.
|
||
func mergeForwardedEnv(env, forwarded []string) []string {
|
||
seen := make(map[string]bool)
|
||
for _, kv := range forwarded {
|
||
k, _, _ := strings.Cut(kv, "=")
|
||
seen[k] = true
|
||
}
|
||
for _, kv := range slices.Backward(env) {
|
||
if k, _, ok := strings.Cut(kv, "="); ok && clientDerivedEnvKey(k) && !seen[k] {
|
||
forwarded = append(forwarded, kv)
|
||
seen[k] = true
|
||
}
|
||
}
|
||
return forwarded
|
||
}
|
||
|
||
// 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() or,
|
||
// on the monitor path, by waiting on waitProcess's exit-notification
|
||
// wait.
|
||
//
|
||
// On unix with a tailscaled binary the child is exec'd by the conn's
|
||
// ssh-monitor via launchViaMonitor; the direct-exec fallback, with no
|
||
// tailscaled binary, and plan9 keep the direct start.
|
||
//
|
||
// It sets ss.cmd, stdin, stdout, and stderr.
|
||
func (ss *sshSession) launchProcess() error {
|
||
var err error
|
||
var forwardedEnv []string
|
||
ss.cmd, forwardedEnv, err = ss.newIncubatorCommand(ss.logf)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
cmd := ss.cmd
|
||
cmd.Env = ss.incubatorEnv()
|
||
|
||
// Pass default TERM and locale variables through the environment pipe.
|
||
// Policy-forwarded values take precedence over these defaults.
|
||
if ss.conn.srv.tailscaledPath != "" {
|
||
forwardedEnv = mergeForwardedEnv(cmd.Env, forwardedEnv)
|
||
}
|
||
// Add --env-fd if the merged environment is non-empty.
|
||
if len(forwardedEnv) > 0 && !slices.Contains(cmd.Args, fmt.Sprintf("--env-fd=%d", forwardedEnvChildFD)) {
|
||
cmd.Args = append(cmd.Args, fmt.Sprintf("--env-fd=%d", forwardedEnvChildFD))
|
||
}
|
||
|
||
// Create the environment pipe for incubator sessions. Close the
|
||
// parent copy after launch; the child then owns its copy.
|
||
var envFile *os.File
|
||
if len(forwardedEnv) > 0 && ss.conn.srv.tailscaledPath != "" {
|
||
envFile, err = forwardedEnvFile(forwardedEnv)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer envFile.Close()
|
||
cmd.ExtraFiles = []*os.File{envFile}
|
||
}
|
||
|
||
ptyReq, winCh, isPty := ss.Pty()
|
||
if !isPty {
|
||
ss.logf("starting non-pty command: %+v", cmd.Args)
|
||
return ss.startWithStdPipes(envFile)
|
||
}
|
||
|
||
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(envFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
defer pty.Close()
|
||
// Use independently closeable, pollable master descriptors for stdin,
|
||
// stdout, and window changes. The original master is not used for I/O.
|
||
var masters [3]*os.File
|
||
for i := range masters {
|
||
masters[i], err = dupPTYMaster(pty)
|
||
if err != nil {
|
||
for _, f := range masters {
|
||
if f != nil {
|
||
f.Close()
|
||
}
|
||
}
|
||
tty.Close()
|
||
return err
|
||
}
|
||
}
|
||
ss.ptyResize = masters[2]
|
||
go resizeWindow(ss.ctx, masters[2], winCh)
|
||
|
||
ss.wrStdin = masters[0]
|
||
ss.rdStdout = masters[1]
|
||
ss.rdStderr = nil // not available for pty
|
||
ss.childPipes = []io.Closer{tty}
|
||
|
||
return nil
|
||
}
|
||
|
||
// dupPTYMaster duplicates a PTY master with CLOEXEC and O_NONBLOCK set before
|
||
// os.NewFile registers it with the runtime poller. Descriptor operations on the
|
||
// returned file must use SyscallConn to synchronize with Close.
|
||
func dupPTYMaster(f *os.File) (*os.File, error) {
|
||
raw, err := f.SyscallConn()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
fd := -1
|
||
var dupErr error
|
||
if err := raw.Control(func(src uintptr) {
|
||
fd, dupErr = unix.FcntlInt(src, unix.F_DUPFD_CLOEXEC, 0)
|
||
if dupErr == nil {
|
||
dupErr = unix.SetNonblock(fd, true)
|
||
}
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
if dupErr != nil {
|
||
if fd != -1 {
|
||
unix.Close(fd)
|
||
}
|
||
return nil, dupErr
|
||
}
|
||
dup := os.NewFile(uintptr(fd), f.Name())
|
||
// Reject descriptors that the runtime cannot poll: Close must interrupt I/O.
|
||
if err := dup.SetReadDeadline(time.Time{}); err != nil {
|
||
dup.Close()
|
||
return nil, fmt.Errorf("PTY master is not pollable: %w", err)
|
||
}
|
||
return dup, nil
|
||
}
|
||
|
||
// sessionViaMonitor reports whether this session's child is exec'd by
|
||
// the conn's ssh-monitor instead of being started directly by
|
||
// tailscaled. The monitor route requires a tailscaled binary; plan9
|
||
// has no monitor.
|
||
func (ss *sshSession) sessionViaMonitor() bool {
|
||
return ss.conn.srv.tailscaledPath != ""
|
||
}
|
||
|
||
// resizeWindow applies window changes until cancellation, channel closure,
|
||
// or a non-EINTR ioctl error. It closes its PTY master descriptor on return.
|
||
func resizeWindow(ctx context.Context, f *os.File, winCh <-chan gliderssh.Window) {
|
||
defer f.Close()
|
||
raw, err := f.SyscallConn()
|
||
if err != nil {
|
||
return
|
||
}
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case win, ok := <-winCh:
|
||
if !ok {
|
||
return
|
||
}
|
||
var ioctlErr error
|
||
// Control prevents concurrent Close from releasing the descriptor
|
||
// while the ioctl is using it.
|
||
if err := raw.Control(func(fd uintptr) {
|
||
for {
|
||
ioctlErr = unix.IoctlSetWinsize(int(fd), syscall.TIOCSWINSZ, &unix.Winsize{
|
||
Row: uint16(win.Height),
|
||
Col: uint16(win.Width),
|
||
Xpixel: uint16(win.WidthPixels),
|
||
Ypixel: uint16(win.HeightPixels),
|
||
})
|
||
if !errors.Is(ioctlErr, unix.EINTR) || ctx.Err() != nil {
|
||
break
|
||
}
|
||
}
|
||
}); err != nil || ioctlErr != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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{
|
||
ssh.VINTR: "intr",
|
||
ssh.VQUIT: "quit",
|
||
ssh.VERASE: "erase",
|
||
ssh.VKILL: "kill",
|
||
ssh.VEOF: "eof",
|
||
ssh.VEOL: "eol",
|
||
ssh.VEOL2: "eol2",
|
||
ssh.VSTART: "start",
|
||
ssh.VSTOP: "stop",
|
||
ssh.VSUSP: "susp",
|
||
ssh.VDSUSP: "dsusp",
|
||
ssh.VREPRINT: "rprnt",
|
||
ssh.VWERASE: "werase",
|
||
ssh.VLNEXT: "lnext",
|
||
ssh.VFLUSH: "flush",
|
||
ssh.VSWTCH: "swtch",
|
||
ssh.VSTATUS: "status",
|
||
ssh.VDISCARD: "discard",
|
||
ssh.IGNPAR: "ignpar",
|
||
ssh.PARMRK: "parmrk",
|
||
ssh.INPCK: "inpck",
|
||
ssh.ISTRIP: "istrip",
|
||
ssh.INLCR: "inlcr",
|
||
ssh.IGNCR: "igncr",
|
||
ssh.ICRNL: "icrnl",
|
||
ssh.IUCLC: "iuclc",
|
||
ssh.IXON: "ixon",
|
||
ssh.IXANY: "ixany",
|
||
ssh.IXOFF: "ixoff",
|
||
ssh.IMAXBEL: "imaxbel",
|
||
ssh.IUTF8: "iutf8",
|
||
ssh.ISIG: "isig",
|
||
ssh.ICANON: "icanon",
|
||
ssh.XCASE: "xcase",
|
||
ssh.ECHO: "echo",
|
||
ssh.ECHOE: "echoe",
|
||
ssh.ECHOK: "echok",
|
||
ssh.ECHONL: "echonl",
|
||
ssh.NOFLSH: "noflsh",
|
||
ssh.TOSTOP: "tostop",
|
||
ssh.IEXTEN: "iexten",
|
||
ssh.ECHOCTL: "echoctl",
|
||
ssh.ECHOKE: "echoke",
|
||
ssh.PENDIN: "pendin",
|
||
ssh.OPOST: "opost",
|
||
ssh.OLCUC: "olcuc",
|
||
ssh.ONLCR: "onlcr",
|
||
ssh.OCRNL: "ocrnl",
|
||
ssh.ONOCR: "onocr",
|
||
ssh.ONLRET: "onlret",
|
||
ssh.CS7: "cs7",
|
||
ssh.CS8: "cs8",
|
||
ssh.PARENB: "parenb",
|
||
ssh.PARODD: "parodd",
|
||
ssh.TTY_OP_ISPEED: "tty_op_ispeed",
|
||
ssh.TTY_OP_OSPEED: "tty_op_ospeed",
|
||
}
|
||
|
||
// startWithPTY starts the session with a pseudo-terminal as its stdio.
|
||
// On the monitor path, the PTY child descriptors are passed via SCM_RIGHTS.
|
||
func (ss *sshSession) startWithPTY(envFile *os.File) (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 == ssh.TTY_OP_ISPEED {
|
||
tios.Ispeed = int(v)
|
||
continue
|
||
}
|
||
if c == ssh.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)
|
||
}
|
||
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))
|
||
}
|
||
|
||
// The pty request's TERM is assembled in incubatorEnv with the rest
|
||
// of the client-derived pairs, so it reaches the incubator through
|
||
// the accepted-pairs channel; see launchProcess.
|
||
|
||
if ss.sessionViaMonitor() {
|
||
ss.logf("starting pty command via session monitor: %+v", cmd.Args)
|
||
if err := ss.launchViaMonitor(cmd, envFile, []*os.File{tty, tty, tty}, true); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return ptyFile, tty, nil
|
||
}
|
||
|
||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||
Setctty: true,
|
||
Setsid: true,
|
||
}
|
||
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.
|
||
// On the monitor path the child is exec'd by the conn's ssh-monitor via
|
||
// launchViaMonitor, which also carries the env-fd pipe; tailscaled
|
||
// keeps its own pipe ends in childPipes.
|
||
func (ss *sshSession) startWithStdPipes(envFile *os.File) (err error) {
|
||
var rdStdin, wrStdout, wrStderr *os.File
|
||
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.childPipes = []io.Closer{rdStdin, wrStdout, wrStderr}
|
||
if ss.sessionViaMonitor() {
|
||
return ss.launchViaMonitor(ss.cmd, envFile, []*os.File{rdStdin, wrStdout, wrStderr}, false)
|
||
}
|
||
ss.cmd.Stdin = rdStdin
|
||
ss.cmd.Stdout = wrStdout
|
||
ss.cmd.Stderr = wrStderr
|
||
return ss.cmd.Start()
|
||
}
|
||
|
||
func envForUser(u *userMeta) []string {
|
||
return []string{
|
||
fmt.Sprintf("SHELL=%s", u.LoginShell()),
|
||
fmt.Sprintf("USER=%s", u.Username),
|
||
// Some systems require $LOGNAME. Just like OpenSSH sshd, set it to the
|
||
// same value as $USER.
|
||
fmt.Sprintf("LOGNAME=%s", u.Username),
|
||
fmt.Sprintf("HOME=%s", u.HomeDir),
|
||
fmt.Sprintf("PATH=%s", defaultPathForUser(u)),
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// The value reaches root-exec'd login/su environments, where glibc treats
|
||
// a LANG/LC_* value containing "/" as a path to locale data and a leading
|
||
// "-" as its own options; TERM is a terminal capability name, not a path.
|
||
// Reject path- and option-like values (and NUL, which would truncate on
|
||
// exec) rather than trying to sanitize them.
|
||
func acceptEnvPair(kv string) bool {
|
||
k, v, ok := strings.Cut(kv, "=")
|
||
if !ok || isDangerousEnvVar(k) || forbiddenEnvKey(k) {
|
||
return false
|
||
}
|
||
if strings.Contains(v, "\x00") {
|
||
return false
|
||
}
|
||
if k == "TERM" {
|
||
return !strings.Contains(v, "/")
|
||
}
|
||
if k == "LANG" || strings.HasPrefix(k, "LC_") {
|
||
return !strings.Contains(v, "/") && !strings.HasPrefix(v, "-")
|
||
}
|
||
return false
|
||
}
|
||
|
||
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.cmd != "" {
|
||
args = append(args, ia.loginShell, "-c", ia.cmd)
|
||
}
|
||
|
||
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 shellArgs(isShell bool, cmd string) []string {
|
||
if isShell {
|
||
if runtime.GOOS == freebsd || runtime.GOOS == openbsd {
|
||
// bsd shells don't support the "-l" option, so we can't run as a login shell
|
||
return []string{}
|
||
}
|
||
return []string{"-l"}
|
||
} else {
|
||
return []string{"-c", cmd}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|