Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413// 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)
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 TestDoDropPrivilegesfunc 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 }
// We need to be able to close stdin and stdout separately later so make // dups. Use F_DUPFD_CLOEXEC rather than dup(2): a plain dup does not set // FD_CLOEXEC, so the pty master would be inherited by every other SSH // session's child process started while this session is alive. A leaked // master keeps the pty open after this session ends, so its slave never // hangs up and processes writing to it (e.g. a tmux server) block forever. // // The resize goroutine owns the second dup, so a window-change // request acts on a descriptor held open for its goroutine's // lifetime. outDup, err := unix.FcntlInt(pty.Fd(), unix.F_DUPFD_CLOEXEC, 0) if err != nil { pty.Close() tty.Close() return err } resizeDup, err := unix.FcntlInt(pty.Fd(), unix.F_DUPFD_CLOEXEC, 0) if err != nil { unix.Close(outDup) pty.Close() tty.Close() return err } go resizeWindow(os.NewFile(uintptr(resizeDup), pty.Name()), winCh)
ss.wrStdin = pty ss.rdStdout = os.NewFile(uintptr(outDup), pty.Name()) ss.rdStderr = nil // not available for pty ss.childPipes = []io.Closer{tty}
return 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-change requests to the session's pty// master. It stops when the SSH channel closes its window channel or// when the ioctl on the master fails. f is this goroutine's own// descriptor of the master, held open for the goroutine's lifetime.func resizeWindow(f *os.File, winCh <-chan gliderssh.Window) { defer f.Close() for win := range winCh { if err := unix.IoctlSetWinsize(int(f.Fd()), syscall.TIOCSWINSZ, &unix.Winsize{ Row: uint16(win.Height), Col: uint16(win.Width), Xpixel: uint16(win.WidthPixels), Ypixel: uint16(win.HeightPixels), }); err != 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 slave 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)}