incubator.go 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. // This file contains the code for the incubator process. Tailscaled
  4. // launches the incubator as the same user as it was launched as. The
  5. // incubator then registers a new session with the OS, sets its UID
  6. // and groups to the specified `--uid`, `--gid` and `--groups`, and
  7. // then launches the requested `--cmd`.
  8. //go:build linux || (darwin && !ios) || freebsd || openbsd
  9. package tailssh
  10. import (
  11. "errors"
  12. "flag"
  13. "fmt"
  14. "io"
  15. "log"
  16. "log/syslog"
  17. "os"
  18. "os/exec"
  19. "path/filepath"
  20. "runtime"
  21. "slices"
  22. "sort"
  23. "strconv"
  24. "strings"
  25. "sync/atomic"
  26. "syscall"
  27. "github.com/creack/pty"
  28. "github.com/pkg/sftp"
  29. "github.com/u-root/u-root/pkg/termios"
  30. gossh "golang.org/x/crypto/ssh"
  31. "golang.org/x/sys/unix"
  32. "tailscale.com/cmd/tailscaled/childproc"
  33. "tailscale.com/hostinfo"
  34. "tailscale.com/tailcfg"
  35. "tailscale.com/tempfork/gliderlabs/ssh"
  36. "tailscale.com/types/logger"
  37. "tailscale.com/version/distro"
  38. )
  39. func init() {
  40. childproc.Add("ssh", beIncubator)
  41. childproc.Add("sftp", beSFTP)
  42. }
  43. var ptyName = func(f *os.File) (string, error) {
  44. return "", fmt.Errorf("unimplemented")
  45. }
  46. // maybeStartLoginSession informs the system that we are about to log someone
  47. // in. On success, it may return a non-nil close func which must be closed to
  48. // release the session.
  49. // We can only do this if we are running as root.
  50. // This is best effort to still allow running on machines where
  51. // we don't support starting sessions, e.g. darwin.
  52. // See maybeStartLoginSessionLinux.
  53. var maybeStartLoginSession = func(dlogf logger.Logf, ia incubatorArgs) (close func() error) {
  54. return nil
  55. }
  56. // newIncubatorCommand returns a new exec.Cmd configured with
  57. // `tailscaled be-child ssh` as the entrypoint.
  58. //
  59. // If ss.srv.tailscaledPath is empty, this method is equivalent to
  60. // exec.CommandContext.
  61. //
  62. // The returned Cmd.Env is guaranteed to be nil; the caller populates it.
  63. func (ss *sshSession) newIncubatorCommand(logf logger.Logf) (cmd *exec.Cmd, err error) {
  64. defer func() {
  65. if cmd.Env != nil {
  66. panic("internal error")
  67. }
  68. }()
  69. var isSFTP, isShell bool
  70. switch ss.Subsystem() {
  71. case "sftp":
  72. isSFTP = true
  73. case "":
  74. isShell = ss.RawCommand() == ""
  75. default:
  76. panic(fmt.Sprintf("unexpected subsystem: %v", ss.Subsystem()))
  77. }
  78. if ss.conn.srv.tailscaledPath == "" {
  79. if isSFTP {
  80. // SFTP relies on the embedded Go-based SFTP server in tailscaled,
  81. // so without tailscaled, we can't serve SFTP.
  82. return nil, errors.New("no tailscaled found on path, can't serve SFTP")
  83. }
  84. loginShell := ss.conn.localUser.LoginShell()
  85. args := shellArgs(isShell, ss.RawCommand())
  86. logf("directly running %s %q", loginShell, args)
  87. return exec.CommandContext(ss.ctx, loginShell, args...), nil
  88. }
  89. lu := ss.conn.localUser
  90. ci := ss.conn.info
  91. groups := strings.Join(ss.conn.userGroupIDs, ",")
  92. remoteUser := ci.uprof.LoginName
  93. if ci.node.IsTagged() {
  94. remoteUser = strings.Join(ci.node.Tags().AsSlice(), ",")
  95. }
  96. incubatorArgs := []string{
  97. "be-child",
  98. "ssh",
  99. "--login-shell=" + lu.LoginShell(),
  100. "--uid=" + lu.Uid,
  101. "--gid=" + lu.Gid,
  102. "--groups=" + groups,
  103. "--local-user=" + lu.Username,
  104. "--remote-user=" + remoteUser,
  105. "--remote-ip=" + ci.src.Addr().String(),
  106. "--has-tty=false", // updated in-place by startWithPTY
  107. "--tty-name=", // updated in-place by startWithPTY
  108. }
  109. // We have to check the below outside of the incubator process, because it
  110. // relies on the "getenforce" command being on the PATH, which it is not
  111. // when in the incubator.
  112. if runtime.GOOS == "linux" && hostinfo.IsSELinuxEnforcing() {
  113. incubatorArgs = append(incubatorArgs, "--is-selinux-enforcing")
  114. }
  115. forceV1Behavior := ss.conn.srv.lb.NetMap().HasCap(tailcfg.NodeAttrSSHBehaviorV1)
  116. if forceV1Behavior {
  117. incubatorArgs = append(incubatorArgs, "--force-v1-behavior")
  118. }
  119. if debugTest.Load() {
  120. incubatorArgs = append(incubatorArgs, "--debug-test")
  121. }
  122. switch {
  123. case isSFTP:
  124. // Note that we include both the `--sftp` flag and a command to launch
  125. // tailscaled as `be-child sftp`. If login or su is available, and
  126. // we're not running with tailcfg.NodeAttrSSHBehaviorV1, this will
  127. // result in serving SFTP within a login shell, with full PAM
  128. // integration. Otherwise, we'll serve SFTP in the incubator process
  129. // with no PAM integration.
  130. incubatorArgs = append(incubatorArgs, "--sftp", fmt.Sprintf("--cmd=%s be-child sftp", ss.conn.srv.tailscaledPath))
  131. case isShell:
  132. incubatorArgs = append(incubatorArgs, "--shell")
  133. default:
  134. incubatorArgs = append(incubatorArgs, "--cmd="+ss.RawCommand())
  135. }
  136. return exec.CommandContext(ss.ctx, ss.conn.srv.tailscaledPath, incubatorArgs...), nil
  137. }
  138. var debugIncubator bool
  139. var debugTest atomic.Bool
  140. type stdRWC struct{}
  141. func (stdRWC) Read(p []byte) (n int, err error) {
  142. return os.Stdin.Read(p)
  143. }
  144. func (stdRWC) Write(b []byte) (n int, err error) {
  145. return os.Stdout.Write(b)
  146. }
  147. func (stdRWC) Close() error {
  148. os.Exit(0)
  149. return nil
  150. }
  151. type incubatorArgs struct {
  152. loginShell string
  153. uid int
  154. gid int
  155. gids []int
  156. localUser string
  157. remoteUser string
  158. remoteIP string
  159. ttyName string
  160. hasTTY bool
  161. cmd string
  162. isSFTP bool
  163. isShell bool
  164. forceV1Behavior bool
  165. debugTest bool
  166. isSELinuxEnforcing bool
  167. }
  168. func parseIncubatorArgs(args []string) (incubatorArgs, error) {
  169. var ia incubatorArgs
  170. var groups string
  171. flags := flag.NewFlagSet("", flag.ExitOnError)
  172. flags.StringVar(&ia.loginShell, "login-shell", "", "path to the user's preferred login shell")
  173. flags.IntVar(&ia.uid, "uid", 0, "the uid of local-user")
  174. flags.IntVar(&ia.gid, "gid", 0, "the gid of local-user")
  175. flags.StringVar(&groups, "groups", "", "comma-separated list of gids of local-user")
  176. flags.StringVar(&ia.localUser, "local-user", "", "the user to run as")
  177. flags.StringVar(&ia.remoteUser, "remote-user", "", "the remote user/tags")
  178. flags.StringVar(&ia.remoteIP, "remote-ip", "", "the remote Tailscale IP")
  179. flags.StringVar(&ia.ttyName, "tty-name", "", "the tty name (pts/3)")
  180. flags.BoolVar(&ia.hasTTY, "has-tty", false, "is the output attached to a tty")
  181. flags.StringVar(&ia.cmd, "cmd", "", "the cmd to launch, including all arguments (ignored in sftp mode)")
  182. flags.BoolVar(&ia.isShell, "shell", false, "is launching a shell (with no cmds)")
  183. flags.BoolVar(&ia.isSFTP, "sftp", false, "run sftp server (cmd is ignored)")
  184. flags.BoolVar(&ia.forceV1Behavior, "force-v1-behavior", false, "allow falling back to the su command if login is unavailable")
  185. flags.BoolVar(&ia.debugTest, "debug-test", false, "should debug in test mode")
  186. flags.BoolVar(&ia.isSELinuxEnforcing, "is-selinux-enforcing", false, "whether SELinux is in enforcing mode")
  187. flags.Parse(args)
  188. for _, g := range strings.Split(groups, ",") {
  189. gid, err := strconv.Atoi(g)
  190. if err != nil {
  191. return ia, fmt.Errorf("unable to parse group id %q: %w", g, err)
  192. }
  193. ia.gids = append(ia.gids, gid)
  194. }
  195. return ia, nil
  196. }
  197. // beIncubator is the entrypoint to the `tailscaled be-child ssh` subcommand.
  198. // It is responsible for informing the system of a new login session for the
  199. // user. This is sometimes necessary for mounting home directories and
  200. // decrypting file systems.
  201. //
  202. // Tailscaled launches the incubator as the same user as it was launched as.
  203. func beIncubator(args []string) error {
  204. // To defend against issues like https://golang.org/issue/1435,
  205. // defensively lock our current goroutine's thread to the current
  206. // system thread before we start making any UID/GID/group changes.
  207. //
  208. // This shouldn't matter on Linux because syscall.AllThreadsSyscall is
  209. // used to invoke syscalls on all OS threads, but (as of 2023-03-23)
  210. // that function is not implemented on all platforms.
  211. runtime.LockOSThread()
  212. defer runtime.UnlockOSThread()
  213. ia, err := parseIncubatorArgs(args)
  214. if err != nil {
  215. return err
  216. }
  217. if ia.isSFTP && ia.isShell {
  218. return fmt.Errorf("--sftp and --shell are mutually exclusive")
  219. }
  220. dlogf := logger.Discard
  221. if debugIncubator {
  222. // We don't own stdout or stderr, so the only place we can log is syslog.
  223. if sl, err := syslog.New(syslog.LOG_INFO|syslog.LOG_DAEMON, "tailscaled-ssh"); err == nil {
  224. dlogf = log.New(sl, "", 0).Printf
  225. }
  226. } else if ia.debugTest {
  227. // In testing, we don't always have syslog, so log to a temp file.
  228. if logFile, err := os.OpenFile("/tmp/tailscalessh.log", os.O_APPEND|os.O_WRONLY, 0666); err == nil {
  229. lf := log.New(logFile, "", 0)
  230. dlogf = func(msg string, args ...any) {
  231. lf.Printf(msg, args...)
  232. logFile.Sync()
  233. }
  234. defer logFile.Close()
  235. }
  236. }
  237. if !shouldAttemptLoginShell(dlogf, ia) {
  238. dlogf("not attempting login shell")
  239. return handleInProcess(dlogf, ia)
  240. }
  241. // First try the login command
  242. if err := tryExecLogin(dlogf, ia); err != nil {
  243. return err
  244. }
  245. // If we got here, we weren't able to use login (because tryExecLogin
  246. // returned without replacing the running process), maybe we can use
  247. // su.
  248. if handled, err := trySU(dlogf, ia); handled {
  249. return err
  250. } else {
  251. dlogf("not attempting su")
  252. return handleInProcess(dlogf, ia)
  253. }
  254. }
  255. func handleInProcess(dlogf logger.Logf, ia incubatorArgs) error {
  256. if ia.isSFTP {
  257. return handleSFTPInProcess(dlogf, ia)
  258. }
  259. return handleSSHInProcess(dlogf, ia)
  260. }
  261. func handleSFTPInProcess(dlogf logger.Logf, ia incubatorArgs) error {
  262. dlogf("handling sftp")
  263. sessionCloser := maybeStartLoginSession(dlogf, ia)
  264. if sessionCloser != nil {
  265. defer sessionCloser()
  266. }
  267. if err := dropPrivileges(dlogf, ia); err != nil {
  268. return err
  269. }
  270. return serveSFTP()
  271. }
  272. // beSFTP serves SFTP in-process.
  273. func beSFTP(args []string) error {
  274. return serveSFTP()
  275. }
  276. func serveSFTP() error {
  277. server, err := sftp.NewServer(stdRWC{})
  278. if err != nil {
  279. return err
  280. }
  281. // TODO(https://github.com/pkg/sftp/pull/554): Revert the check for io.EOF,
  282. // when sftp is patched to report clean termination.
  283. if err := server.Serve(); err != nil && err != io.EOF {
  284. return err
  285. }
  286. return nil
  287. }
  288. // shouldAttemptLoginShell decides whether we should attempt to get a full
  289. // login shell with the login or su commands. We will attempt a login shell
  290. // if all of the following conditions are met.
  291. //
  292. // - We are running as root
  293. // - This is not an SELinuxEnforcing host
  294. //
  295. // The last condition exists because if we're running on a SELinux-enabled
  296. // system, neiher login nor su will be able to set the correct context for the
  297. // shell. So, we don't bother trying to run them and instead fall back to using
  298. // the incubator to launch the shell.
  299. // See http://github.com/tailscale/tailscale/issues/4908.
  300. func shouldAttemptLoginShell(dlogf logger.Logf, ia incubatorArgs) bool {
  301. if ia.forceV1Behavior && ia.isSFTP {
  302. // v1 behavior did not run SFTP within a login shell.
  303. dlogf("Forcing v1 behavior, won't use login shell for SFTP")
  304. return false
  305. }
  306. return runningAsRoot() && !ia.isSELinuxEnforcing
  307. }
  308. func runningAsRoot() bool {
  309. euid := os.Geteuid()
  310. return euid == 0
  311. }
  312. // tryExecLogin attempts to handle the ssh session by creating a full login
  313. // shell using the login command. If it never tried, it returns nil. If it
  314. // failed to do so, it returns an error.
  315. //
  316. // Creating a login shell in this way allows us to register the remote IP of
  317. // the login session, trigger PAM authentication, and get the "remote" PAM
  318. // profile.
  319. //
  320. // However, login is subject to some limitations.
  321. //
  322. // 1. login cannot be used to execute commands except on macOS.
  323. // 2. On Linux and BSD, login requires a TTY to keep running.
  324. //
  325. // In these cases, tryExecLogin returns (false, nil) to indicate that processing
  326. // should fall through to other methods, such as using the su command.
  327. //
  328. // Note that this uses unix.Exec to replace the current process, so in cases
  329. // where we actually do run login, no subsequent Go code will execute.
  330. func tryExecLogin(dlogf logger.Logf, ia incubatorArgs) error {
  331. // Only the macOS version of the login command supports executing a
  332. // command, all other versions only support launching a shell without
  333. // taking any arguments.
  334. if !ia.isShell && runtime.GOOS != "darwin" {
  335. dlogf("won't use login because we're not in a shell or on macOS")
  336. return nil
  337. }
  338. switch runtime.GOOS {
  339. case "linux", "freebsd", "openbsd":
  340. if !ia.hasTTY {
  341. dlogf("can't use login because of missing TTY")
  342. // We can only use the login command if a shell was requested with
  343. // a TTY. If there is no TTY, login exits immediately, which
  344. // breaks things like mosh and VSCode.
  345. return nil
  346. }
  347. }
  348. loginCmdPath, err := exec.LookPath("login")
  349. if err != nil {
  350. dlogf("failed to get login args: %s", err)
  351. return nil
  352. }
  353. loginArgs := ia.loginArgs(loginCmdPath)
  354. dlogf("logging in with %s %+v", loginCmdPath, loginArgs)
  355. // If Exec works, the Go code will not proceed past this:
  356. err = unix.Exec(loginCmdPath, loginArgs, os.Environ())
  357. // If we made it here, Exec failed.
  358. return err
  359. }
  360. // trySU attempts to start a login shell using su. If su is available and
  361. // supports the necessary arguments, this returns true, plus the result of
  362. // executing su. Otherwise, it returns (false, nil).
  363. //
  364. // Creating a login shell in this way allows us to trigger PAM authentication
  365. // and get the "login" PAM profile.
  366. //
  367. // Unlike login, su often does not require a TTY, so on Linux hosts that have
  368. // an su command which accepts the right flags, we'll use su instead of login
  369. // when no TTY is available.
  370. func trySU(dlogf logger.Logf, ia incubatorArgs) (handled bool, err error) {
  371. if ia.forceV1Behavior {
  372. // v1 behavior did not use su.
  373. dlogf("Forcing v1 behavior, won't use su")
  374. return false, nil
  375. }
  376. su := findSU(dlogf, ia)
  377. if su == "" {
  378. return false, nil
  379. }
  380. sessionCloser := maybeStartLoginSession(dlogf, ia)
  381. if sessionCloser != nil {
  382. defer sessionCloser()
  383. }
  384. loginArgs := []string{"-l", ia.localUser}
  385. if ia.cmd != "" {
  386. // Note - unlike the login command, su allows using both -l and -c.
  387. loginArgs = append(loginArgs, "-c", ia.cmd)
  388. }
  389. dlogf("logging in with %s %q", su, loginArgs)
  390. // If Exec works, the Go code will not proceed past this:
  391. err = unix.Exec(su, loginArgs, os.Environ())
  392. // If we made it here, Exec failed.
  393. return true, err
  394. }
  395. // findSU attempts to find an su command which supports the -l and -c flags.
  396. // This actually calls the su command, which can cause side effects like
  397. // triggering pam_mkhomedir. If a suitable su is not available, this returns
  398. // "".
  399. func findSU(dlogf logger.Logf, ia incubatorArgs) string {
  400. // Currently, we only support falling back to su on Linux. This
  401. // potentially could work on BSDs as well, but requires testing.
  402. if runtime.GOOS != "linux" {
  403. return ""
  404. }
  405. // gokrazy doesn't include su. And, if someone installs a breakglass/
  406. // debugging package on gokrazy, we don't want to use its su.
  407. if distro.Get() == distro.Gokrazy {
  408. return ""
  409. }
  410. su, err := exec.LookPath("su")
  411. if err != nil {
  412. dlogf("can't find su command: %v", err)
  413. return ""
  414. }
  415. // First try to execute su -l <user> -c true to make sure su supports the
  416. // necessary arguments.
  417. err = exec.Command(su, "-l", ia.localUser, "-c", "true").Run()
  418. if err != nil {
  419. dlogf("su check failed: %s", err)
  420. return ""
  421. }
  422. return su
  423. }
  424. // handleSSHInProcess is a last resort if we couldn't use login or su. It
  425. // registers a new session with the OS, sets its UID, GID and groups to the
  426. // specified values, and then launches the requested `--cmd` in the user's
  427. // login shell.
  428. func handleSSHInProcess(dlogf logger.Logf, ia incubatorArgs) error {
  429. sessionCloser := maybeStartLoginSession(dlogf, ia)
  430. if sessionCloser != nil {
  431. defer sessionCloser()
  432. }
  433. if err := dropPrivileges(dlogf, ia); err != nil {
  434. return err
  435. }
  436. args := shellArgs(ia.isShell, ia.cmd)
  437. dlogf("running %s %q", ia.loginShell, args)
  438. cmd := newCommand(ia.hasTTY, ia.loginShell, args)
  439. err := cmd.Run()
  440. if ee, ok := err.(*exec.ExitError); ok {
  441. ps := ee.ProcessState
  442. code := ps.ExitCode()
  443. if code < 0 {
  444. // TODO(bradfitz): do we need to also check the syscall.WaitStatus
  445. // and make our process look like it also died by signal/same signal
  446. // as our child process? For now we just do the exit code.
  447. fmt.Fprintf(os.Stderr, "[tailscale-ssh: process died: %v]\n", ps.String())
  448. code = 1 // for now. so we don't exit with negative
  449. }
  450. os.Exit(code)
  451. }
  452. return err
  453. }
  454. func newCommand(hasTTY bool, cmdPath string, cmdArgs []string) *exec.Cmd {
  455. cmd := exec.Command(cmdPath, cmdArgs...)
  456. cmd.Stdin = os.Stdin
  457. cmd.Stdout = os.Stdout
  458. cmd.Stderr = os.Stderr
  459. cmd.Env = os.Environ()
  460. if hasTTY {
  461. // If we were launched with a tty then we should mark that as the ctty
  462. // of the child. However, as the ctty is being passed from the parent
  463. // we set the child to foreground instead which also passes the ctty.
  464. // However, we can not do this if never had a tty to begin with.
  465. cmd.SysProcAttr = &syscall.SysProcAttr{
  466. Foreground: true,
  467. }
  468. }
  469. return cmd
  470. }
  471. const (
  472. // This controls whether we assert that our privileges were dropped
  473. // using geteuid/getegid; it's a const and not an envknob because the
  474. // incubator doesn't see the parent's environment.
  475. //
  476. // TODO(andrew): remove this const and always do this after sufficient
  477. // testing, e.g. the 1.40 release
  478. assertPrivilegesWereDropped = true
  479. // TODO(andrew-d): verify that this works in more configurations before
  480. // enabling by default.
  481. assertPrivilegesWereDroppedByAttemptingToUnDrop = false
  482. )
  483. // dropPrivileges calls doDropPrivileges with uid, gid, and gids from the given
  484. // incubatorArgs.
  485. func dropPrivileges(dlogf logger.Logf, ia incubatorArgs) error {
  486. return doDropPrivileges(dlogf, ia.uid, ia.gid, ia.gids)
  487. }
  488. // doDropPrivileges contains all the logic for dropping privileges to a different
  489. // UID, GID, and set of supplementary groups. This function is
  490. // security-sensitive and ordering-dependent; please be very cautious if/when
  491. // refactoring.
  492. //
  493. // WARNING: if you change this function, you *MUST* run the TestDoDropPrivileges
  494. // test in this package as root on at least Linux, FreeBSD and Darwin. This can
  495. // be done by running:
  496. //
  497. // go test -c ./ssh/tailssh/ && sudo ./tailssh.test -test.v -test.run TestDoDropPrivileges
  498. func doDropPrivileges(dlogf logger.Logf, wantUid, wantGid int, supplementaryGroups []int) error {
  499. dlogf("dropping privileges")
  500. fatalf := func(format string, args ...any) {
  501. dlogf("[unexpected] error dropping privileges: "+format, args...)
  502. os.Exit(1)
  503. }
  504. euid := os.Geteuid()
  505. egid := os.Getegid()
  506. if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" {
  507. // On FreeBSD and Darwin, the first entry returned from the
  508. // getgroups(2) syscall is the egid, and changing it with
  509. // setgroups(2) changes the egid of the process. This is
  510. // technically a violation of the POSIX standard; see the
  511. // following article for more detail:
  512. // https://www.usenix.org/system/files/login/articles/325-tsafrir.pdf
  513. //
  514. // In this case, we add an entry at the beginning of the
  515. // groupIDs list containing the expected gid if it's not
  516. // already there, which modifies the egid and additional groups
  517. // as one unit.
  518. if len(supplementaryGroups) == 0 || supplementaryGroups[0] != wantGid {
  519. supplementaryGroups = append([]int{wantGid}, supplementaryGroups...)
  520. }
  521. }
  522. if err := setGroups(supplementaryGroups); err != nil {
  523. return err
  524. }
  525. if egid != wantGid {
  526. // On FreeBSD and Darwin, we may have already called the
  527. // equivalent of setegid(wantGid) via the call to setGroups,
  528. // above. However, per the manpage, setgid(getegid()) is an
  529. // allowed operation regardless of privilege level.
  530. //
  531. // FreeBSD:
  532. // The setgid() system call is permitted if the specified ID
  533. // is equal to the real group ID or the effective group ID
  534. // of the process, or if the effective user ID is that of
  535. // the super user.
  536. //
  537. // Darwin:
  538. // The setgid() function is permitted if the effective
  539. // user ID is that of the super user, or if the specified
  540. // group ID is the same as the effective group ID. If
  541. // not, but the specified group ID is the same as the real
  542. // group ID, setgid() will set the effective group ID to
  543. // the real group ID.
  544. if err := syscall.Setgid(wantGid); err != nil {
  545. fatalf("Setgid(%d): %v", wantGid, err)
  546. }
  547. }
  548. if euid != wantUid {
  549. // Switch users if required before starting the desired process.
  550. if err := syscall.Setuid(wantUid); err != nil {
  551. fatalf("Setuid(%d): %v", wantUid, err)
  552. }
  553. }
  554. // If we changed either the UID or GID, defensively assert that we
  555. // cannot reset the it back to our original values, and that the
  556. // current egid/euid are the expected values after we change
  557. // everything; if not, we exit the process.
  558. if assertPrivilegesWereDroppedByAttemptingToUnDrop {
  559. if egid != wantGid {
  560. if err := syscall.Setegid(egid); err == nil {
  561. fatalf("able to set egid back to %d", egid)
  562. }
  563. }
  564. if euid != wantUid {
  565. if err := syscall.Seteuid(euid); err == nil {
  566. fatalf("able to set euid back to %d", euid)
  567. }
  568. }
  569. }
  570. if assertPrivilegesWereDropped {
  571. if got := os.Getegid(); got != wantGid {
  572. fatalf("got egid=%d, want %d", got, wantGid)
  573. }
  574. if got := os.Geteuid(); got != wantUid {
  575. fatalf("got euid=%d, want %d", got, wantUid)
  576. }
  577. // TODO(andrew-d): assert that our supplementary groups are correct
  578. }
  579. return nil
  580. }
  581. // launchProcess launches an incubator process for the provided session.
  582. // It is responsible for configuring the process execution environment.
  583. // The caller can wait for the process to exit by calling cmd.Wait().
  584. //
  585. // It sets ss.cmd, stdin, stdout, and stderr.
  586. func (ss *sshSession) launchProcess() error {
  587. var err error
  588. ss.cmd, err = ss.newIncubatorCommand(ss.logf)
  589. if err != nil {
  590. return err
  591. }
  592. cmd := ss.cmd
  593. homeDir := ss.conn.localUser.HomeDir
  594. if _, err := os.Stat(homeDir); err == nil {
  595. cmd.Dir = homeDir
  596. } else if os.IsNotExist(err) {
  597. // If the home directory doesn't exist, we can't chdir to it.
  598. // Instead, we'll chdir to the root directory.
  599. cmd.Dir = "/"
  600. } else {
  601. return err
  602. }
  603. cmd.Env = envForUser(ss.conn.localUser)
  604. for _, kv := range ss.Environ() {
  605. if acceptEnvPair(kv) {
  606. cmd.Env = append(cmd.Env, kv)
  607. }
  608. }
  609. ci := ss.conn.info
  610. cmd.Env = append(cmd.Env,
  611. fmt.Sprintf("SSH_CLIENT=%s %d %d", ci.src.Addr(), ci.src.Port(), ci.dst.Port()),
  612. fmt.Sprintf("SSH_CONNECTION=%s %d %s %d", ci.src.Addr(), ci.src.Port(), ci.dst.Addr(), ci.dst.Port()),
  613. )
  614. if ss.agentListener != nil {
  615. cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_AUTH_SOCK=%s", ss.agentListener.Addr()))
  616. }
  617. ptyReq, winCh, isPty := ss.Pty()
  618. if !isPty {
  619. ss.logf("starting non-pty command: %+v", cmd.Args)
  620. return ss.startWithStdPipes()
  621. }
  622. if sshDisablePTY() {
  623. ss.logf("pty support disabled by envknob")
  624. return errors.New("pty support disabled by envknob")
  625. }
  626. ss.ptyReq = &ptyReq
  627. pty, tty, err := ss.startWithPTY()
  628. if err != nil {
  629. return err
  630. }
  631. // We need to be able to close stdin and stdout separately later so make a
  632. // dup.
  633. ptyDup, err := syscall.Dup(int(pty.Fd()))
  634. if err != nil {
  635. pty.Close()
  636. tty.Close()
  637. return err
  638. }
  639. go resizeWindow(ptyDup /* arbitrary fd */, winCh)
  640. ss.wrStdin = pty
  641. ss.rdStdout = os.NewFile(uintptr(ptyDup), pty.Name())
  642. ss.rdStderr = nil // not available for pty
  643. ss.childPipes = []io.Closer{tty}
  644. return nil
  645. }
  646. func resizeWindow(fd int, winCh <-chan ssh.Window) {
  647. for win := range winCh {
  648. unix.IoctlSetWinsize(fd, syscall.TIOCSWINSZ, &unix.Winsize{
  649. Row: uint16(win.Height),
  650. Col: uint16(win.Width),
  651. })
  652. }
  653. }
  654. // opcodeShortName is a mapping of SSH opcode
  655. // to mnemonic names expected by the termios package.
  656. // These are meant to be platform independent.
  657. var opcodeShortName = map[uint8]string{
  658. gossh.VINTR: "intr",
  659. gossh.VQUIT: "quit",
  660. gossh.VERASE: "erase",
  661. gossh.VKILL: "kill",
  662. gossh.VEOF: "eof",
  663. gossh.VEOL: "eol",
  664. gossh.VEOL2: "eol2",
  665. gossh.VSTART: "start",
  666. gossh.VSTOP: "stop",
  667. gossh.VSUSP: "susp",
  668. gossh.VDSUSP: "dsusp",
  669. gossh.VREPRINT: "rprnt",
  670. gossh.VWERASE: "werase",
  671. gossh.VLNEXT: "lnext",
  672. gossh.VFLUSH: "flush",
  673. gossh.VSWTCH: "swtch",
  674. gossh.VSTATUS: "status",
  675. gossh.VDISCARD: "discard",
  676. gossh.IGNPAR: "ignpar",
  677. gossh.PARMRK: "parmrk",
  678. gossh.INPCK: "inpck",
  679. gossh.ISTRIP: "istrip",
  680. gossh.INLCR: "inlcr",
  681. gossh.IGNCR: "igncr",
  682. gossh.ICRNL: "icrnl",
  683. gossh.IUCLC: "iuclc",
  684. gossh.IXON: "ixon",
  685. gossh.IXANY: "ixany",
  686. gossh.IXOFF: "ixoff",
  687. gossh.IMAXBEL: "imaxbel",
  688. gossh.IUTF8: "iutf8",
  689. gossh.ISIG: "isig",
  690. gossh.ICANON: "icanon",
  691. gossh.XCASE: "xcase",
  692. gossh.ECHO: "echo",
  693. gossh.ECHOE: "echoe",
  694. gossh.ECHOK: "echok",
  695. gossh.ECHONL: "echonl",
  696. gossh.NOFLSH: "noflsh",
  697. gossh.TOSTOP: "tostop",
  698. gossh.IEXTEN: "iexten",
  699. gossh.ECHOCTL: "echoctl",
  700. gossh.ECHOKE: "echoke",
  701. gossh.PENDIN: "pendin",
  702. gossh.OPOST: "opost",
  703. gossh.OLCUC: "olcuc",
  704. gossh.ONLCR: "onlcr",
  705. gossh.OCRNL: "ocrnl",
  706. gossh.ONOCR: "onocr",
  707. gossh.ONLRET: "onlret",
  708. gossh.CS7: "cs7",
  709. gossh.CS8: "cs8",
  710. gossh.PARENB: "parenb",
  711. gossh.PARODD: "parodd",
  712. gossh.TTY_OP_ISPEED: "tty_op_ispeed",
  713. gossh.TTY_OP_OSPEED: "tty_op_ospeed",
  714. }
  715. // startWithPTY starts cmd with a pseudo-terminal attached to Stdin, Stdout and Stderr.
  716. func (ss *sshSession) startWithPTY() (ptyFile, tty *os.File, err error) {
  717. ptyReq := ss.ptyReq
  718. cmd := ss.cmd
  719. if cmd == nil {
  720. return nil, nil, errors.New("nil ss.cmd")
  721. }
  722. if ptyReq == nil {
  723. return nil, nil, errors.New("nil ss.ptyReq")
  724. }
  725. ptyFile, tty, err = pty.Open()
  726. if err != nil {
  727. err = fmt.Errorf("pty.Open: %w", err)
  728. return
  729. }
  730. defer func() {
  731. if err != nil {
  732. ptyFile.Close()
  733. tty.Close()
  734. }
  735. }()
  736. ptyRawConn, err := tty.SyscallConn()
  737. if err != nil {
  738. return nil, nil, fmt.Errorf("SyscallConn: %w", err)
  739. }
  740. var ctlErr error
  741. if err := ptyRawConn.Control(func(fd uintptr) {
  742. // Load existing PTY settings to modify them & save them back.
  743. tios, err := termios.GTTY(int(fd))
  744. if err != nil {
  745. ctlErr = fmt.Errorf("GTTY: %w", err)
  746. return
  747. }
  748. // Set the rows & cols to those advertised from the ptyReq frame
  749. // received over SSH.
  750. tios.Row = int(ptyReq.Window.Height)
  751. tios.Col = int(ptyReq.Window.Width)
  752. for c, v := range ptyReq.Modes {
  753. if c == gossh.TTY_OP_ISPEED {
  754. tios.Ispeed = int(v)
  755. continue
  756. }
  757. if c == gossh.TTY_OP_OSPEED {
  758. tios.Ospeed = int(v)
  759. continue
  760. }
  761. k, ok := opcodeShortName[c]
  762. if !ok {
  763. ss.vlogf("unknown opcode: %d", c)
  764. continue
  765. }
  766. if _, ok := tios.CC[k]; ok {
  767. tios.CC[k] = uint8(v)
  768. continue
  769. }
  770. if _, ok := tios.Opts[k]; ok {
  771. tios.Opts[k] = v > 0
  772. continue
  773. }
  774. ss.vlogf("unsupported opcode: %v(%d)=%v", k, c, v)
  775. }
  776. // Save PTY settings.
  777. if _, err := tios.STTY(int(fd)); err != nil {
  778. ctlErr = fmt.Errorf("STTY: %w", err)
  779. return
  780. }
  781. }); err != nil {
  782. return nil, nil, fmt.Errorf("ptyRawConn.Control: %w", err)
  783. }
  784. if ctlErr != nil {
  785. return nil, nil, fmt.Errorf("ptyRawConn.Control func: %w", ctlErr)
  786. }
  787. cmd.SysProcAttr = &syscall.SysProcAttr{
  788. Setctty: true,
  789. Setsid: true,
  790. }
  791. updateStringInSlice(cmd.Args, "--has-tty=false", "--has-tty=true")
  792. if ptyName, err := ptyName(ptyFile); err == nil {
  793. updateStringInSlice(cmd.Args, "--tty-name=", "--tty-name="+ptyName)
  794. fullPath := filepath.Join("/dev", ptyName)
  795. cmd.Env = append(cmd.Env, fmt.Sprintf("SSH_TTY=%s", fullPath))
  796. }
  797. if ptyReq.Term != "" {
  798. cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))
  799. }
  800. cmd.Stdin = tty
  801. cmd.Stdout = tty
  802. cmd.Stderr = tty
  803. ss.logf("starting pty command: %+v", cmd.Args)
  804. if err = cmd.Start(); err != nil {
  805. return
  806. }
  807. return ptyFile, tty, nil
  808. }
  809. // startWithStdPipes starts cmd with os.Pipe for Stdin, Stdout and Stderr.
  810. func (ss *sshSession) startWithStdPipes() (err error) {
  811. var rdStdin, wrStdout, wrStderr io.ReadWriteCloser
  812. defer func() {
  813. if err != nil {
  814. closeAll(rdStdin, ss.wrStdin, ss.rdStdout, wrStdout, ss.rdStderr, wrStderr)
  815. }
  816. }()
  817. if ss.cmd == nil {
  818. return errors.New("nil cmd")
  819. }
  820. if rdStdin, ss.wrStdin, err = os.Pipe(); err != nil {
  821. return err
  822. }
  823. if ss.rdStdout, wrStdout, err = os.Pipe(); err != nil {
  824. return err
  825. }
  826. if ss.rdStderr, wrStderr, err = os.Pipe(); err != nil {
  827. return err
  828. }
  829. ss.cmd.Stdin = rdStdin
  830. ss.cmd.Stdout = wrStdout
  831. ss.cmd.Stderr = wrStderr
  832. ss.childPipes = []io.Closer{rdStdin, wrStdout, wrStderr}
  833. return ss.cmd.Start()
  834. }
  835. func envForUser(u *userMeta) []string {
  836. return []string{
  837. fmt.Sprintf("SHELL=" + u.LoginShell()),
  838. fmt.Sprintf("USER=" + u.Username),
  839. fmt.Sprintf("HOME=" + u.HomeDir),
  840. fmt.Sprintf("PATH=" + defaultPathForUser(&u.User)),
  841. }
  842. }
  843. // updateStringInSlice mutates ss to change the first occurrence of a
  844. // to b.
  845. func updateStringInSlice(ss []string, a, b string) {
  846. for i, s := range ss {
  847. if s == a {
  848. ss[i] = b
  849. return
  850. }
  851. }
  852. }
  853. // acceptEnvPair reports whether the environment variable key=value pair
  854. // should be accepted from the client. It uses the same default as OpenSSH
  855. // AcceptEnv.
  856. func acceptEnvPair(kv string) bool {
  857. k, _, ok := strings.Cut(kv, "=")
  858. if !ok {
  859. return false
  860. }
  861. return k == "TERM" || k == "LANG" || strings.HasPrefix(k, "LC_")
  862. }
  863. func fileExists(path string) bool {
  864. _, err := os.Stat(path)
  865. return err == nil
  866. }
  867. // loginArgs returns the arguments to use to exec the login binary.
  868. func (ia *incubatorArgs) loginArgs(loginCmdPath string) []string {
  869. switch runtime.GOOS {
  870. case "darwin":
  871. args := []string{
  872. loginCmdPath,
  873. "-f", // already authenticated
  874. // login typically discards the previous environment, but we want to
  875. // preserve any environment variables that we currently have.
  876. "-p",
  877. "-h", ia.remoteIP, // -h is "remote host"
  878. ia.localUser,
  879. }
  880. if !ia.hasTTY {
  881. args[2] = "-pq" // -q is "quiet" which suppresses the login banner
  882. }
  883. if ia.cmd != "" {
  884. args = append(args, ia.loginShell, "-c", ia.cmd)
  885. }
  886. return args
  887. case "linux":
  888. if distro.Get() == distro.Arch && !fileExists("/etc/pam.d/remote") {
  889. // See https://github.com/tailscale/tailscale/issues/4924
  890. //
  891. // Arch uses a different login binary that makes the -h flag set the PAM
  892. // service to "remote". So if they don't have that configured, don't
  893. // pass -h.
  894. return []string{loginCmdPath, "-f", ia.localUser, "-p"}
  895. }
  896. return []string{loginCmdPath, "-f", ia.localUser, "-h", ia.remoteIP, "-p"}
  897. case "freebsd", "openbsd":
  898. return []string{loginCmdPath, "-fp", "-h", ia.remoteIP, ia.localUser}
  899. }
  900. panic("unimplemented")
  901. }
  902. func shellArgs(isShell bool, cmd string) []string {
  903. if isShell {
  904. return []string{"-l"}
  905. } else {
  906. return []string{"-c", cmd}
  907. }
  908. }
  909. func setGroups(groupIDs []int) error {
  910. if runtime.GOOS == "darwin" && len(groupIDs) > 16 {
  911. // darwin returns "invalid argument" if more than 16 groups are passed to syscall.Setgroups
  912. // some info can be found here:
  913. // https://opensource.apple.com/source/samba/samba-187.8/patches/support-darwin-initgroups-syscall.auto.html
  914. // this fix isn't great, as anyone reading this has probably just wasted hours figuring out why
  915. // some permissions thing isn't working, due to some arbitrary group ordering, but it at least allows
  916. // this to work for more things than it previously did.
  917. groupIDs = groupIDs[:16]
  918. }
  919. err := syscall.Setgroups(groupIDs)
  920. if err != nil && os.Geteuid() != 0 && groupsMatchCurrent(groupIDs) {
  921. // If we're not root, ignore a Setgroups failure if all groups are the same.
  922. return nil
  923. }
  924. return err
  925. }
  926. func groupsMatchCurrent(groupIDs []int) bool {
  927. existing, err := syscall.Getgroups()
  928. if err != nil {
  929. return false
  930. }
  931. if len(existing) != len(groupIDs) {
  932. return false
  933. }
  934. groupIDs = slices.Clone(groupIDs)
  935. sort.Ints(groupIDs)
  936. sort.Ints(existing)
  937. return slices.Equal(groupIDs, existing)
  938. }